Compare commits

..

2 Commits

Author SHA1 Message Date
Dhruwang
fe08e7de1f feedback 2026-01-23 13:25:11 +05:30
Dhruwang
0d12e71f99 added response validation to client apis 2026-01-23 12:11:24 +05:30
111 changed files with 1389 additions and 7424 deletions

View File

@@ -1,520 +0,0 @@
"use server";
import { ChartType } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { executeQuery } from "@/app/api/analytics/_lib/cube-client";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZCreateChartAction = z.object({
environmentId: ZId,
name: z.string().min(1),
type: z.enum(["area", "bar", "line", "pie", "big_number", "big_number_total", "table", "funnel", "map"]),
query: z.record(z.any()),
config: z.record(z.any()).optional().default({}),
});
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZCreateChartAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const chart = await prisma.chart.create({
data: {
name: parsedInput.name,
type: parsedInput.type as ChartType,
projectId,
query: parsedInput.query,
config: parsedInput.config || {},
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = chart;
return chart;
}
)
);
const ZUpdateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
name: z.string().min(1).optional(),
type: z
.enum(["area", "bar", "line", "pie", "big_number", "big_number_total", "table", "funnel", "map"])
.optional(),
query: z.record(z.any()).optional(),
config: z.record(z.any()).optional(),
});
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
withAuditLogging(
"updated",
"chart",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZUpdateChartAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Verify chart belongs to the project
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!chart) {
throw new Error("Chart not found");
}
const updateData: any = {};
if (parsedInput.name !== undefined) updateData.name = parsedInput.name;
if (parsedInput.type !== undefined) updateData.type = parsedInput.type as ChartType;
if (parsedInput.query !== undefined) updateData.query = parsedInput.query;
if (parsedInput.config !== undefined) updateData.config = parsedInput.config;
const updatedChart = await prisma.chart.update({
where: { id: parsedInput.chartId },
data: updateData,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = chart;
ctx.auditLoggingCtx.newObject = updatedChart;
return updatedChart;
}
)
);
const ZAddChartToDashboardAction = z.object({
environmentId: ZId,
chartId: ZId,
dashboardId: ZId,
title: z.string().optional(),
layout: z
.object({
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
})
.optional()
.default({ x: 0, y: 0, w: 4, h: 3 }),
});
export const addChartToDashboardAction = authenticatedActionClient.schema(ZAddChartToDashboardAction).action(
withAuditLogging(
"created",
"dashboardWidget",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZAddChartToDashboardAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Verify chart and dashboard belong to the same project
const [chart, dashboard] = await Promise.all([
prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
}),
prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
}),
]);
if (!chart) {
throw new Error("Chart not found");
}
if (!dashboard) {
throw new Error("Dashboard not found");
}
// Get the max order for widgets in this dashboard
const maxOrder = await prisma.dashboardWidget.aggregate({
where: { dashboardId: parsedInput.dashboardId },
_max: { order: true },
});
const widget = await prisma.dashboardWidget.create({
data: {
dashboardId: parsedInput.dashboardId,
chartId: parsedInput.chartId,
type: "chart",
title: parsedInput.title,
layout: parsedInput.layout,
order: (maxOrder._max.order ?? -1) + 1,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = widget;
return widget;
}
)
);
const ZCreateDashboardAction = z.object({
environmentId: ZId,
name: z.string().min(1),
description: z.string().optional(),
});
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
withAuditLogging(
"created",
"dashboard",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZCreateDashboardAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const dashboard = await prisma.dashboard.create({
data: {
name: parsedInput.name,
description: parsedInput.description,
projectId,
status: "draft",
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = dashboard;
return dashboard;
}
)
);
const ZUpdateDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
status: z.enum(["draft", "published"]).optional(),
});
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
withAuditLogging(
"updated",
"dashboard",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZUpdateDashboardAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Verify dashboard belongs to the project
const dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
const updateData: any = {};
if (parsedInput.name !== undefined) updateData.name = parsedInput.name;
if (parsedInput.description !== undefined) updateData.description = parsedInput.description;
if (parsedInput.status !== undefined) updateData.status = parsedInput.status;
const updatedDashboard = await prisma.dashboard.update({
where: { id: parsedInput.dashboardId },
data: updateData,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = dashboard;
ctx.auditLoggingCtx.newObject = updatedDashboard;
return updatedDashboard;
}
)
);
const ZGetDashboardsAction = z.object({
environmentId: ZId,
});
export const getDashboardsAction = authenticatedActionClient
.schema(ZGetDashboardsAction)
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZGetDashboardsAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId,
},
],
});
const dashboards = await prisma.dashboard.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
description: true,
status: true,
createdAt: true,
updatedAt: true,
},
});
return dashboards;
});
const ZGetChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const getChartAction = authenticatedActionClient
.schema(ZGetChartAction)
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZGetChartAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId,
},
],
});
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
select: {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
},
});
if (!chart) {
throw new Error("Chart not found");
}
return chart;
});
const ZGetChartsAction = z.object({
environmentId: ZId,
});
export const getChartsAction = authenticatedActionClient
.schema(ZGetChartsAction)
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZGetChartsAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId,
},
],
});
const charts = await prisma.chart.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
type: true,
createdAt: true,
updatedAt: true,
query: true,
config: true,
widgets: {
select: {
dashboardId: true,
},
},
},
});
return charts;
});
const ZExecuteQueryAction = z.object({
environmentId: ZId,
query: z.object({
measures: z.array(z.string()),
dimensions: z.array(z.string()).optional(),
timeDimensions: z
.array(
z.object({
dimension: z.string(),
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).optional(),
dateRange: z.union([z.string(), z.array(z.string())]).optional(),
})
)
.optional(),
filters: z
.array(
z.object({
member: z.string(),
operator: z.enum([
"equals",
"notEquals",
"contains",
"notContains",
"set",
"notSet",
"gt",
"gte",
"lt",
"lte",
]),
values: z.array(z.string()).optional().nullable(),
})
)
.optional(),
}),
});
export const executeQueryAction = authenticatedActionClient
.schema(ZExecuteQueryAction)
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZExecuteQueryAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId,
},
],
});
try {
const data = await executeQuery(parsedInput.query);
return { data };
} catch (error: any) {
return { error: error.message || "Failed to execute query" };
}
});

View File

@@ -1,92 +0,0 @@
"use client";
import { ActivityIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface AIQuerySectionProps {
onChartGenerated: (data: AnalyticsResponse) => void;
}
export function AIQuerySection({ onChartGenerated }: AIQuerySectionProps) {
const [userQuery, setUserQuery] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const handleGenerate = async () => {
if (!userQuery.trim()) return;
setIsGenerating(true);
try {
const response = await fetch("/api/analytics/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: userQuery }),
});
const data = await response.json();
if (data.error) {
toast.error(data.error);
return;
}
if (data.data) {
onChartGenerated(data);
} else {
toast.error("No data returned from query");
}
} catch (error: any) {
toast.error(error.message || "Failed to generate chart");
} finally {
setIsGenerating(false);
}
};
return (
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<div className="bg-brand-dark/10 flex h-8 w-8 items-center justify-center rounded-full">
<ActivityIcon className="text-brand-dark h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-gray-900">Ask your data</h2>
<p className="text-sm text-gray-500">Describe what you want to see and let AI build the chart.</p>
</div>
</div>
<div className="flex gap-4">
<Input
placeholder="e.g. How many users signed up last week?"
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && userQuery.trim() && !isGenerating) {
handleGenerate();
}
}}
className="flex-1"
disabled={isGenerating}
/>
<Button
disabled={!userQuery.trim() || isGenerating}
loading={isGenerating}
className="bg-brand-dark hover:bg-brand-dark/90"
onClick={handleGenerate}>
Generate
</Button>
</div>
{isGenerating && (
<div className="flex items-center justify-center py-12">
<LoadingSpinner className="h-8 w-8" />
<span className="ml-3 text-sm text-gray-500">Generating chart...</span>
</div>
)}
</div>
);
}

View File

@@ -1,111 +0,0 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface Dashboard {
id: string;
name: string;
}
interface AddToDashboardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chartName: string;
onChartNameChange: (name: string) => void;
dashboards: Dashboard[];
selectedDashboardId: string;
onDashboardSelect: (id: string) => void;
onAdd: () => void;
isSaving: boolean;
}
export function AddToDashboardDialog({
open,
onOpenChange,
chartName,
onChartNameChange,
dashboards,
selectedDashboardId,
onDashboardSelect,
onAdd,
isSaving,
}: AddToDashboardDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Chart to Dashboard</DialogTitle>
<DialogDescription>
Select a dashboard to add this chart to. The chart will be saved automatically.
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-4">
<div>
<label htmlFor="chart-name" className="mb-2 block text-sm font-medium text-gray-700">
Chart Name
</label>
<Input
id="chart-name"
placeholder="Chart name"
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
/>
</div>
<div>
<label htmlFor="dashboard-select" className="mb-2 block text-sm font-medium text-gray-700">
Dashboard
</label>
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
<SelectTrigger id="dashboard-select" className="w-full bg-white">
<SelectValue
placeholder={dashboards.length === 0 ? "No dashboards available" : "Select a dashboard"}
/>
</SelectTrigger>
<SelectContent position="popper" className="z-[100] max-h-[200px]">
{dashboards.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-gray-500">No dashboards available</div>
) : (
dashboards.map((dashboard) => (
<SelectItem key={dashboard.id} value={dashboard.id}>
{dashboard.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{dashboards.length === 0 && (
<p className="mt-1 text-xs text-gray-500">Create a dashboard first to add charts to it.</p>
)}
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={onAdd} loading={isSaving} disabled={!selectedDashboardId}>
Add to Dashboard
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,617 +0,0 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CodeIcon, DatabaseIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useEffect, useReducer, useState } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { Button } from "@/modules/ui/components/button";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import {
addChartToDashboardAction,
createChartAction,
executeQueryAction,
getDashboardsAction,
} from "../../actions";
import { CHART_TYPES } from "../lib/chart-types";
// Filter out table, map, and scatter charts
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter(
(type) => !["table", "map", "scatter"].includes(type.id)
);
import { mapChartType } from "../lib/chart-utils";
import {
ChartBuilderState,
CustomMeasure,
FilterRow,
TimeDimensionConfig,
buildCubeQuery,
parseQueryToState,
} from "../lib/query-builder";
import { AddToDashboardDialog } from "./AddToDashboardDialog";
import { ChartRenderer } from "./ChartRenderer";
import { DimensionsPanel } from "./DimensionsPanel";
import { FiltersPanel } from "./FiltersPanel";
import { MeasuresPanel } from "./MeasuresPanel";
import { SaveChartDialog } from "./SaveChartDialog";
import { TimeDimensionPanel } from "./TimeDimensionPanel";
interface AdvancedChartBuilderProps {
environmentId: string;
initialChartType?: string;
initialQuery?: any; // Prefill with AI-generated query
hidePreview?: boolean; // Hide internal preview when using unified preview
onChartGenerated?: (data: AnalyticsResponse) => void;
onSave?: (chartId: string) => void;
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
}
type Action =
| { type: "SET_CHART_TYPE"; payload: string }
| { type: "ADD_MEASURE"; payload: string }
| { type: "REMOVE_MEASURE"; payload: string }
| { type: "SET_MEASURES"; payload: string[] }
| { type: "ADD_CUSTOM_MEASURE"; payload: CustomMeasure }
| { type: "UPDATE_CUSTOM_MEASURE"; payload: { index: number; measure: CustomMeasure } }
| { type: "REMOVE_CUSTOM_MEASURE"; payload: number }
| { type: "SET_CUSTOM_MEASURES"; payload: CustomMeasure[] }
| { type: "SET_DIMENSIONS"; payload: string[] }
| { type: "ADD_FILTER"; payload: FilterRow }
| { type: "UPDATE_FILTER"; payload: { index: number; filter: FilterRow } }
| { type: "REMOVE_FILTER"; payload: number }
| { type: "SET_FILTERS"; payload: FilterRow[] }
| { type: "SET_FILTER_LOGIC"; payload: "and" | "or" }
| { type: "SET_TIME_DIMENSION"; payload: TimeDimensionConfig | null };
const initialState: ChartBuilderState = {
chartType: "",
selectedMeasures: [],
customMeasures: [],
selectedDimensions: [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
function chartBuilderReducer(state: ChartBuilderState, action: Action): ChartBuilderState {
switch (action.type) {
case "SET_CHART_TYPE":
return { ...state, chartType: action.payload };
case "SET_MEASURES":
return { ...state, selectedMeasures: action.payload };
case "SET_CUSTOM_MEASURES":
return { ...state, customMeasures: action.payload };
case "SET_DIMENSIONS":
return { ...state, selectedDimensions: action.payload };
case "SET_FILTERS":
return { ...state, filters: action.payload };
case "SET_FILTER_LOGIC":
return { ...state, filterLogic: action.payload };
case "SET_TIME_DIMENSION":
return { ...state, timeDimension: action.payload };
default:
return state;
}
}
export function AdvancedChartBuilder({
environmentId,
initialChartType,
initialQuery,
hidePreview = false,
onChartGenerated,
onSave,
onAddToDashboard,
}: AdvancedChartBuilderProps) {
const router = useRouter();
// Initialize state from initialQuery if provided
const getInitialState = (): ChartBuilderState => {
if (initialQuery) {
const parsedState = parseQueryToState(initialQuery, initialChartType);
return {
...initialState,
...parsedState,
chartType: parsedState.chartType || initialChartType || "",
};
}
return {
...initialState,
chartType: initialChartType || "",
};
};
const [state, dispatch] = useReducer(chartBuilderReducer, getInitialState());
const [chartData, setChartData] = useState<Record<string, any>[] | null>(null);
const [query, setQuery] = useState<any>(initialQuery || null);
const [isInitialized, setIsInitialized] = useState(false);
const lastStateRef = React.useRef<string>("");
// Sync initialChartType prop changes to state
useEffect(() => {
if (initialChartType && initialChartType !== state.chartType) {
dispatch({ type: "SET_CHART_TYPE", payload: initialChartType });
// If there's no initialQuery, mark as initialized so reactive updates can work
if (!initialQuery && !isInitialized) {
setIsInitialized(true);
}
}
}, [initialChartType, state.chartType, initialQuery, isInitialized]);
// Initialize: If initialQuery is provided (from AI), execute it and set chart data
useEffect(() => {
if (initialQuery && !isInitialized) {
setIsInitialized(true);
executeQueryAction({
environmentId,
query: initialQuery,
}).then((result) => {
if (result?.data?.data) {
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setQuery(initialQuery);
// Set initial state hash to prevent reactive update on initial load
lastStateRef.current = JSON.stringify({
chartType: state.chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
// Call onChartGenerated if provided
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: initialQuery,
chartType: state.chartType as any,
data,
};
onChartGenerated(analyticsResponse);
}
}
});
}
}, [initialQuery, environmentId, isInitialized, state.chartType, state.selectedMeasures, state.selectedDimensions, state.filters, state.timeDimension, onChartGenerated]);
// Update preview reactively when state changes (after initialization)
useEffect(() => {
// Skip if not initialized or no chart type selected
if (!isInitialized || !state.chartType) return;
// Create a hash of relevant state to detect changes
const stateHash = JSON.stringify({
chartType: state.chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
// Only update if state actually changed
if (stateHash === lastStateRef.current) return;
lastStateRef.current = stateHash;
// If chart type changed but we have existing data, update the chart type in preview immediately
// This handles the case where user changes chart type from ManualChartBuilder
if (chartData && Array.isArray(chartData) && chartData.length > 0 && query) {
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: query, // Keep existing query
chartType: state.chartType as any, // Update chart type
data: chartData, // Keep existing data
};
onChartGenerated(analyticsResponse);
}
}
// Only execute query if we have measures configured
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
return; // Don't execute query without measures
}
// Build and execute query with current state
const updatedQuery = buildCubeQuery(state);
setIsLoading(true);
setError(null);
executeQueryAction({
environmentId,
query: updatedQuery,
})
.then((result) => {
if (result?.data?.data) {
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setQuery(updatedQuery);
// Call onChartGenerated to update parent preview
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: updatedQuery,
chartType: state.chartType as any,
data,
};
onChartGenerated(analyticsResponse);
}
} else if (result?.serverError) {
setError(result.serverError);
}
})
.catch((err: any) => {
setError(err.message || "Failed to execute query");
})
.finally(() => {
setIsLoading(false);
});
}, [state.chartType, state.selectedMeasures, state.selectedDimensions, state.filters, state.timeDimension, isInitialized, environmentId, onChartGenerated, chartData, query]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [showQuery, setShowQuery] = useState(false);
const [showData, setShowData] = useState(false);
useEffect(() => {
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (result?.data) {
setDashboards(result.data);
} else if (result?.serverError) {
toast.error(result.serverError);
}
});
}
}, [isAddToDashboardDialogOpen, environmentId]);
const handleRunQuery = async () => {
if (!state.chartType) {
toast.error("Please select a chart type");
return;
}
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
toast.error("Please select at least one measure");
return;
}
setIsLoading(true);
setError(null);
try {
const cubeQuery = buildCubeQuery(state);
setQuery(cubeQuery);
const result = await executeQueryAction({
environmentId,
query: cubeQuery,
});
if (result?.serverError) {
setError(result.serverError);
toast.error(result.serverError);
setChartData(null);
} else if (result?.data?.data) {
// Ensure data is always an array - result.data.data contains the actual array
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setError(null);
toast.success("Query executed successfully");
// Call onChartGenerated callback if provided
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: cubeQuery,
chartType: state.chartType as any,
data,
};
onChartGenerated(analyticsResponse);
}
} else {
throw new Error("No data returned");
}
} catch (err: any) {
const errorMessage = err.message || "Failed to execute query";
setError(errorMessage);
toast.error(errorMessage);
setChartData(null);
} finally {
setIsLoading(false);
}
};
const handleSaveChart = async () => {
if (!chartData || !chartName.trim()) {
toast.error("Please enter a chart name");
return;
}
if (!query) {
toast.error("Please run a query first");
return;
}
setIsSaving(true);
try {
const result = await createChartAction({
environmentId,
name: chartName,
type: mapChartType(state.chartType),
query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to save chart");
return;
}
toast.success("Chart saved successfully!");
setIsSaveDialogOpen(false);
if (onSave) {
onSave(result.data.id);
} else {
router.push(`/environments/${environmentId}/analysis/charts`);
}
} catch (error: any) {
toast.error(error.message || "Failed to save chart");
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error("Please select a dashboard");
return;
}
if (!query) {
toast.error("Please run a query first");
return;
}
setIsSaving(true);
console.log(query);
try {
const chartResult = await createChartAction({
environmentId,
name: chartName || `Chart ${new Date().toLocaleString()}`,
type: mapChartType(state.chartType),
query,
config: {},
});
if (!chartResult?.data) {
toast.error(chartResult?.serverError || "Failed to save chart");
return;
}
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartResult.data.id,
dashboardId: selectedDashboardId,
});
if (!widgetResult?.data) {
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
return;
}
toast.success("Chart added to dashboard!");
setIsAddToDashboardDialogOpen(false);
if (onAddToDashboard) {
onAddToDashboard(chartResult.data.id, selectedDashboardId);
} else {
router.push(`/environments/${environmentId}/analysis/dashboard/${selectedDashboardId}`);
}
} catch (error: any) {
toast.error(error.message || "Failed to add chart to dashboard");
} finally {
setIsSaving(false);
}
};
return (
<div className={hidePreview ? "space-y-4" : "grid gap-4 lg:grid-cols-2"}>
{/* Left Column: Configuration */}
<div className="space-y-4">
{/* Chart Type Selection - Hidden when hidePreview is true (unified flow) */}
{!hidePreview && (
<div className="space-y-4">
<h2 className="font-medium text-gray-900">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) => {
const isSelected = state.chartType === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => dispatch({ type: "SET_CHART_TYPE", payload: chart.id })}
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${isSelected
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
: "border-gray-200 hover:border-gray-300"
}`}>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
</button>
);
})}
</div>
</div>
)}
{/* Measures Panel */}
<MeasuresPanel
selectedMeasures={state.selectedMeasures}
customMeasures={state.customMeasures}
onMeasuresChange={(measures) => dispatch({ type: "SET_MEASURES", payload: measures })}
onCustomMeasuresChange={(measures) => dispatch({ type: "SET_CUSTOM_MEASURES", payload: measures })}
/>
{/* Dimensions Panel */}
<DimensionsPanel
selectedDimensions={state.selectedDimensions}
onDimensionsChange={(dimensions) => dispatch({ type: "SET_DIMENSIONS", payload: dimensions })}
/>
{/* Time Dimension Panel */}
<TimeDimensionPanel
timeDimension={state.timeDimension}
onTimeDimensionChange={(config) => dispatch({ type: "SET_TIME_DIMENSION", payload: config })}
/>
{/* Filters Panel */}
<FiltersPanel
filters={state.filters}
filterLogic={state.filterLogic}
onFiltersChange={(filters) => dispatch({ type: "SET_FILTERS", payload: filters })}
onFilterLogicChange={(logic) => dispatch({ type: "SET_FILTER_LOGIC", payload: logic })}
/>
{/* Action Buttons */}
<div className="flex gap-2">
<Button onClick={handleRunQuery} disabled={isLoading || !state.chartType}>
{isLoading ? <LoadingSpinner /> : "Run Query"}
</Button>
{chartData && !onSave && !onAddToDashboard && (
<>
<Button variant="outline" onClick={() => setIsSaveDialogOpen(true)}>
Save Chart
</Button>
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)}>
Add to Dashboard
</Button>
</>
)}
</div>
</div>
{/* Right Column: Preview - Hidden when hidePreview is true */}
{!hidePreview && (
<div className="space-y-4">
<h3 className="font-medium text-gray-900">Chart Preview</h3>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
)}
{isLoading && (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
)}
{chartData && Array.isArray(chartData) && chartData.length > 0 && !isLoading && (
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<ChartRenderer chartType={state.chartType} data={chartData} />
</div>
{/* Query Viewer */}
<Collapsible.Root open={showQuery} onOpenChange={setShowQuery}>
<Collapsible.CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<CodeIcon className="mr-2 h-4 w-4" />
{showQuery ? "Hide" : "View"} Query
</Button>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="mt-2">
<pre className="max-h-64 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-4 text-xs">
{JSON.stringify(query, null, 2)}
</pre>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
{/* Data Viewer */}
<Collapsible.Root open={showData} onOpenChange={setShowData}>
<Collapsible.CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<DatabaseIcon className="mr-2 h-4 w-4" />
{showData ? "Hide" : "View"} Data
</Button>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="mt-2">
<div className="max-h-64 overflow-auto rounded-lg border border-gray-200">
<table className="w-full text-xs">
<thead className="bg-gray-50">
<tr>
{Array.isArray(chartData) &&
chartData.length > 0 &&
Object.keys(chartData[0]).map((key) => (
<th
key={key}
className="border-b border-gray-200 px-3 py-2 text-left font-medium">
{key}
</th>
))}
</tr>
</thead>
<tbody>
{Array.isArray(chartData) &&
chartData.slice(0, 10).map((row, idx) => {
// Create a unique key from row data
const rowKey = Object.values(row)
.slice(0, 3)
.map((v) => String(v || ""))
.join("-");
return (
<tr key={`row-${idx}-${rowKey}`} className="border-b border-gray-100">
{Object.entries(row).map(([key, value]) => (
<td key={`${rowKey}-${key}`} className="px-3 py-2">
{value?.toString() || "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
{Array.isArray(chartData) && chartData.length > 10 && (
<div className="bg-gray-50 px-3 py-2 text-xs text-gray-500">
Showing first 10 of {chartData.length} rows
</div>
)}
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
)}
{!chartData && !isLoading && !error && (
<div className="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-500">
Configure your chart and click "Run Query" to preview
</div>
)}
</div>
)}
{/* Dialogs - Only render when callbacks are not provided (standalone mode) */}
{!onSave && (
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
)}
{!onAddToDashboard && (
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
)}
</div>
);
}

View File

@@ -1,353 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import {
addChartToDashboardAction,
createChartAction,
executeQueryAction,
getChartAction,
getDashboardsAction,
updateChartAction,
} from "../../actions";
import { mapChartType, mapDatabaseChartTypeToApi } from "../lib/chart-utils";
import { AIQuerySection } from "./AIQuerySection";
import { AddToDashboardDialog } from "./AddToDashboardDialog";
import { AdvancedChartBuilder } from "./AdvancedChartBuilder";
import { ChartPreview } from "./ChartPreview";
import { ConfigureChartDialog } from "./ConfigureChartDialog";
import { ManualChartBuilder } from "./ManualChartBuilder";
import { SaveChartDialog } from "./SaveChartDialog";
interface ChartBuilderClientProps {
environmentId: string;
chartId?: string;
}
export function ChartBuilderClient({ environmentId, chartId }: ChartBuilderClientProps) {
const router = useRouter();
const [selectedChartType, setSelectedChartType] = useState<string>("");
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [isConfigureDialogOpen, setIsConfigureDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [showData, setShowData] = useState(false);
const [configuredChartType, setConfiguredChartType] = useState<string | null>(null);
const [showAdvancedBuilder, setShowAdvancedBuilder] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
useEffect(() => {
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (result?.data) {
setDashboards(result.data);
} else if (result?.serverError) {
toast.error(result.serverError);
}
});
}
}, [isAddToDashboardDialogOpen, environmentId]);
useEffect(() => {
if (chartId) {
setIsLoadingChart(true);
getChartAction({ environmentId, chartId })
.then(async (result) => {
if (result?.data) {
const chart = result.data;
setChartName(chart.name);
// Execute the chart's query to get the data
const queryResult = await executeQueryAction({
environmentId,
query: chart.query as any,
});
if (queryResult?.error || queryResult?.serverError) {
toast.error(queryResult.error || queryResult.serverError || "Failed to load chart data");
setIsLoadingChart(false);
return;
}
if (queryResult?.data?.data) {
// Format as AnalyticsResponse
const chartData: AnalyticsResponse = {
query: chart.query as any,
chartType: mapDatabaseChartTypeToApi(chart.type),
data: Array.isArray(queryResult.data.data) ? queryResult.data.data : [],
};
setChartData(chartData);
setConfiguredChartType(mapDatabaseChartTypeToApi(chart.type));
setCurrentChartId(chart.id);
} else {
toast.error("No data returned for chart");
}
} else if (result?.serverError) {
toast.error(result.serverError);
}
setIsLoadingChart(false);
})
.catch((error: any) => {
toast.error(error.message || "Failed to load chart");
setIsLoadingChart(false);
});
}
}, [chartId, environmentId]);
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
};
const handleSaveChart = async () => {
if (!chartData || !chartName.trim()) {
toast.error("Please enter a chart name");
return;
}
setIsSaving(true);
try {
// If we have a currentChartId, update the existing chart; otherwise create a new one
if (currentChartId) {
const result = await updateChartAction({
environmentId,
chartId: currentChartId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to update chart");
return;
}
toast.success("Chart updated successfully!");
setIsSaveDialogOpen(false);
router.push(`/environments/${environmentId}/analysis/charts`);
} else {
const result = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to save chart");
return;
}
setCurrentChartId(result.data.id);
toast.success("Chart saved successfully!");
setIsSaveDialogOpen(false);
router.push(`/environments/${environmentId}/analysis/charts`);
}
} catch (error: any) {
toast.error(error.message || "Failed to save chart");
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error("Please select a dashboard");
return;
}
setIsSaving(true);
try {
let chartIdToUse = currentChartId;
// If we don't have a chartId (creating new chart), create it first
if (!chartIdToUse) {
if (!chartName.trim()) {
toast.error("Please enter a chart name");
setIsSaving(false);
return;
}
const chartResult = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!chartResult?.data) {
toast.error(chartResult?.serverError || "Failed to save chart");
setIsSaving(false);
return;
}
chartIdToUse = chartResult.data.id;
setCurrentChartId(chartIdToUse);
}
// Add the chart (existing or newly created) to the dashboard
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartIdToUse,
dashboardId: selectedDashboardId,
});
if (!widgetResult?.data) {
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
return;
}
toast.success("Chart added to dashboard!");
setIsAddToDashboardDialogOpen(false);
router.push(`/environments/${environmentId}/analysis/dashboard/${selectedDashboardId}`);
} catch (error: any) {
toast.error(error.message || "Failed to add chart to dashboard");
} finally {
setIsSaving(false);
}
};
const handleManualCreate = () => {
if (!selectedChartType) {
toast.error("Please select a chart type first");
return;
}
setShowAdvancedBuilder(true);
};
// If loading an existing chart, show loading state
if (chartId && isLoadingChart) {
return (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
);
}
// If viewing an existing chart, show only the chart preview
if (chartId && chartData) {
return (
<div className="grid gap-8">
<ChartPreview
chartData={chartData}
configuredChartType={configuredChartType}
showData={showData}
onToggleData={() => setShowData(!showData)}
onConfigure={() => setIsConfigureDialogOpen(true)}
/>
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
<ConfigureChartDialog
open={isConfigureDialogOpen}
onOpenChange={setIsConfigureDialogOpen}
currentChartType={chartData?.chartType || "bar"}
configuredChartType={configuredChartType}
onChartTypeSelect={setConfiguredChartType}
onReset={() => setConfiguredChartType(null)}
/>
</div>
);
}
return (
<div className="grid gap-8">
{/* Option 1: Ask AI */}
<AIQuerySection onChartGenerated={handleChartGenerated} />
{/* Chart Preview */}
{chartData && (
<ChartPreview
chartData={chartData}
configuredChartType={configuredChartType}
showData={showData}
onToggleData={() => setShowData(!showData)}
onConfigure={() => setIsConfigureDialogOpen(true)}
/>
)}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-2 text-sm text-gray-500">OR</span>
</div>
</div>
{/* Option 2: Build Manually */}
{showAdvancedBuilder ? (
<AdvancedChartBuilder environmentId={environmentId} initialChartType={selectedChartType} />
) : (
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={setSelectedChartType}
onCreate={handleManualCreate}
/>
)}
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
<ConfigureChartDialog
open={isConfigureDialogOpen}
onOpenChange={setIsConfigureDialogOpen}
currentChartType={chartData?.chartType || "bar"}
configuredChartType={configuredChartType}
onChartTypeSelect={setConfiguredChartType}
onReset={() => setConfiguredChartType(null)}
/>
</div>
);
}

View File

@@ -1,47 +0,0 @@
"use client";
import { useState } from "react";
import { BarChart, DatabaseIcon } from "lucide-react";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { ChartRenderer } from "./ChartRenderer";
import { DataViewer } from "./DataViewer";
interface ChartPreviewProps {
chartData: AnalyticsResponse;
}
export function ChartPreview({ chartData }: ChartPreviewProps) {
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
return (
<div className="mt-6 space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold text-gray-900">Chart Preview</h3>
</div>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "chart" | "data")}>
<div className="flex justify-end mb-4">
<TabsList>
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
Chart
</TabsTrigger>
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
Data
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chart" className="mt-0">
<ChartRenderer chartType={chartData.chartType} data={chartData.data || []} />
</TabsContent>
<TabsContent value="data" className="mt-0">
<DataViewer data={chartData.data || []} />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -1,229 +0,0 @@
"use client";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
XAxis,
YAxis,
} from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
// Formbricks brand colors
const BRAND_DARK = "#00C4B8";
const BRAND_LIGHT = "#00E6CA";
interface ChartRendererProps {
chartType: string;
data: Record<string, any>[];
}
export function ChartRenderer({ chartType, data }: ChartRendererProps) {
if (!data || data.length === 0) {
return <div className="flex h-64 items-center justify-center text-gray-500">No data available</div>;
}
// Get the first data point to determine keys
const firstRow = data[0];
const allKeys = Object.keys(firstRow);
const keys = allKeys.filter((key) => key !== "date" && key !== "time");
// For pie charts, we need to identify dimension (nameKey) and measure (dataKey)
let xAxisKey = "key";
let dataKey = "value";
if (chartType === "pie" || chartType === "donut") {
// Find first numeric key (measure)
const numericKey = keys.find((key) => {
const firstValue = firstRow[key];
if (firstValue === null || firstValue === undefined || firstValue === "") return false;
const numValue = Number(firstValue);
return !Number.isNaN(numValue) && Number.isFinite(numValue);
});
// Find first non-numeric key (dimension)
const nonNumericKey = keys.find((key) => {
if (key === numericKey) return false;
const firstValue = firstRow[key];
return firstValue !== undefined;
});
xAxisKey = nonNumericKey || (numericKey ? keys.find((k) => k !== numericKey) : null) || keys[0] || "key";
dataKey = numericKey || keys[1] || keys[0] || "value";
} else {
// For other chart types, use existing logic
if (firstRow.date) {
xAxisKey = "date";
} else if (firstRow.time) {
xAxisKey = "time";
} else if (keys[0]) {
xAxisKey = keys[0];
}
dataKey = keys.find((k) => k !== xAxisKey) || keys[0] || "value";
}
switch (chartType) {
case "bar":
return (
<div className="h-64 min-h-[256px] w-full">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey={dataKey} fill={BRAND_DARK} radius={4} />
</BarChart>
</ChartContainer>
</div>
);
case "line":
return (
<div className="h-64 min-h-[256px] w-full">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey={dataKey}
stroke={BRAND_DARK}
strokeWidth={3}
dot={{ fill: BRAND_DARK, r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ChartContainer>
</div>
);
case "area":
return (
<div className="h-64 min-h-[256px] w-full">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full">
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey={dataKey}
stroke={BRAND_DARK}
fill={BRAND_LIGHT}
fillOpacity={0.4}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
</div>
);
case "pie":
case "donut": {
if (!dataKey || !xAxisKey) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
Unable to determine chart data structure
</div>
);
}
// Filter out rows where the dataKey value is null, undefined, or empty
const validData = data.filter((row) => {
const value = row[dataKey];
if (value === null || value === undefined || value === "") return false;
const numValue = Number(value);
return !Number.isNaN(numValue) && Number.isFinite(numValue);
});
// Convert dataKey values to numbers for proper rendering
const processedData = validData.map((row) => ({
...row,
[dataKey]: Number(row[dataKey]),
}));
if (processedData.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">No valid data to display</div>
);
}
// Generate colors using Formbricks brand palette
const colors = processedData.map((_, index) => {
const hue = 180; // Teal base hue
const saturation = 70 + (index % 3) * 10; // Vary saturation
const lightness = 45 + (index % 2) * 15; // Vary lightness
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
});
// Use brand colors for first two slices
if (colors.length > 0) colors[0] = BRAND_DARK;
if (colors.length > 1) colors[1] = BRAND_LIGHT;
return (
<div className="h-64 min-h-[256px] w-full min-w-0">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full min-w-0">
<PieChart width={400} height={256}>
<Pie
data={processedData}
dataKey={dataKey}
nameKey={xAxisKey}
cx="50%"
cy="50%"
outerRadius={80}
label={({ name, percent }) => {
if (!percent) return "";
return `${name}: ${(percent * 100).toFixed(0)}%`;
}}>
{processedData.map((row, index) => {
const rowKey = row[xAxisKey] ?? `row-${index}`;
const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`;
return <Cell key={uniqueKey} fill={colors[index] || BRAND_DARK} />;
})}
</Pie>
<ChartTooltip
content={<ChartTooltipContent />}
formatter={(value: any, name: string) => {
const numValue = Number(value);
return [numValue.toLocaleString(), name];
}}
/>
</PieChart>
</ChartContainer>
</div>
);
}
case "kpi":
case "big_number": {
const total = data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
return (
<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>
</div>
);
}
default:
return (
<div className="flex h-64 items-center justify-center text-gray-500">
Chart type "{chartType}" not yet supported
</div>
);
}
}

View File

@@ -1,94 +0,0 @@
"use client";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { CHART_TYPES } from "../lib/chart-types";
interface ConfigureChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentChartType: string;
configuredChartType: string | null;
onChartTypeSelect: (type: string) => void;
onReset: () => void;
}
export function ConfigureChartDialog({
open,
onOpenChange,
currentChartType,
configuredChartType,
onChartTypeSelect,
onReset,
}: ConfigureChartDialogProps) {
const availableTypes = CHART_TYPES.filter((type) =>
["bar", "line", "area", "pie", "big_number"].includes(type.id)
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Configure Chart</DialogTitle>
<DialogDescription>
Modify the chart type and other settings for this visualization.
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium text-gray-900">Chart Type</h4>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{availableTypes.map((chart) => {
const isSelected = (configuredChartType || currentChartType) === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={cn(
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-all hover:bg-gray-50",
isSelected
? "border-brand-dark bg-brand-dark/5 ring-brand-dark ring-2"
: "border-gray-200"
)}>
<div className="flex h-10 w-10 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-5 w-5 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
</button>
);
})}
</div>
<div className="mt-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onReset} className="text-xs">
Reset to AI suggestion
</Button>
{configuredChartType && (
<span className="text-xs text-gray-500">
Original: {CHART_TYPES.find((t) => t.id === currentChartType)?.name || currentChartType}
</span>
)}
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={() => onOpenChange(false)}>Apply Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,63 +0,0 @@
"use client";
import { DatabaseIcon } from "lucide-react";
interface DataViewerProps {
data: Record<string, any>[];
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function DataViewer({ data }: Omit<DataViewerProps, "isOpen" | "onOpenChange">) {
if (!data || data.length === 0) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<p className="text-sm text-gray-500">No data available</p>
</div>
);
}
const columns = Object.keys(data[0]);
const displayData = data.slice(0, 50);
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-2 flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-gray-600" />
<h4 className="text-sm font-semibold text-gray-900">Chart Data</h4>
</div>
<div className="max-h-64 overflow-auto rounded bg-white">
<table className="w-full text-xs">
<thead className="bg-gray-100">
<tr>
{columns.map((key) => (
<th key={key} className="border-b border-gray-200 px-3 py-2 text-left font-semibold">
{key}
</th>
))}
</tr>
</thead>
<tbody>
{displayData.map((row, index) => {
const rowKey = Object.values(row)[0] ? String(Object.values(row)[0]) : `row-${index}`;
return (
<tr
key={`data-row-${rowKey}-${index}`}
className="border-b border-gray-100 hover:bg-gray-50">
{Object.entries(row).map(([key, value]) => (
<td key={`cell-${key}-${rowKey}`} className="px-3 py-2">
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
{data.length > 50 && (
<div className="px-3 py-2 text-xs text-gray-500">Showing first 50 of {data.length} rows</div>
)}
</div>
</div>
);
}

View File

@@ -1,35 +0,0 @@
"use client";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { FEEDBACK_FIELDS } from "../lib/schema-definition";
interface DimensionsPanelProps {
selectedDimensions: string[];
onDimensionsChange: (dimensions: string[]) => void;
}
export function DimensionsPanel({ selectedDimensions, onDimensionsChange }: DimensionsPanelProps) {
const dimensionOptions = FEEDBACK_FIELDS.dimensions.map((d) => ({
value: d.id,
label: `${d.label}${d.description ? ` - ${d.description}` : ""}`,
}));
return (
<div className="space-y-4">
<h3 className="font-medium text-gray-900">Dimensions</h3>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Group By</label>
<MultiSelect
options={dimensionOptions}
value={selectedDimensions}
onChange={onDimensionsChange}
placeholder="Select dimensions to group by..."
/>
<p className="text-xs text-gray-500">
Select dimensions to break down your data. The order matters for multi-dimensional charts.
</p>
</div>
</div>
);
}

View File

@@ -1,248 +0,0 @@
"use client";
import { Plus, TrashIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { FilterRow } from "../lib/query-builder";
import { FEEDBACK_FIELDS, getFieldById, getFilterOperatorsForType } from "../lib/schema-definition";
interface FiltersPanelProps {
filters: FilterRow[];
filterLogic: "and" | "or";
onFiltersChange: (filters: FilterRow[]) => void;
onFilterLogicChange: (logic: "and" | "or") => void;
}
export function FiltersPanel({
filters,
filterLogic,
onFiltersChange,
onFilterLogicChange,
}: FiltersPanelProps) {
const fieldOptions = [
...FEEDBACK_FIELDS.dimensions.map((d) => ({
value: d.id,
label: d.label,
type: d.type,
})),
...FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: m.label,
type: m.type === "count" ? "number" : "number",
})),
];
const handleAddFilter = () => {
const firstField = fieldOptions[0];
onFiltersChange([
...filters,
{
field: firstField?.value || "",
operator: "equals",
values: null,
},
]);
};
const handleRemoveFilter = (index: number) => {
onFiltersChange(filters.filter((_, i) => i !== index));
};
const handleUpdateFilter = (index: number, updates: Partial<FilterRow>) => {
const updated = [...filters];
updated[index] = { ...updated[index], ...updates };
// Reset values if operator changed to set/notSet
if (updates.operator && (updates.operator === "set" || updates.operator === "notSet")) {
updated[index].values = null;
}
onFiltersChange(updated);
};
const getValueInput = (filter: FilterRow, index: number) => {
const field = getFieldById(filter.field);
const fieldType = field?.type || "string";
const operators = getFilterOperatorsForType(fieldType as "string" | "number" | "time");
// For set/notSet operators, no value input needed
if (filter.operator === "set" || filter.operator === "notSet") {
return null;
}
// For number fields with comparison operators, use number input
if (
fieldType === "number" &&
(filter.operator === "gt" ||
filter.operator === "gte" ||
filter.operator === "lt" ||
filter.operator === "lte")
) {
return (
<Input
type="number"
placeholder="Enter value"
value={filter.values?.[0] || ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [Number(e.target.value)] : null,
})
}
className="w-[150px]"
/>
);
}
// For equals/notEquals with string fields, allow single value
if ((filter.operator === "equals" || filter.operator === "notEquals") && fieldType === "string") {
return (
<Input
placeholder="Enter value"
value={filter.values?.[0] || ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [e.target.value] : null,
})
}
className="w-[200px]"
/>
);
}
// For contains/notContains, allow multiple values (multi-select)
if (filter.operator === "contains" || filter.operator === "notContains") {
// For now, use a simple input - could be enhanced with multi-select
return (
<Input
placeholder="Enter value"
value={filter.values?.[0] || ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [e.target.value] : null,
})
}
className="w-[200px]"
/>
);
}
// Default: single value input
return (
<Input
placeholder="Enter value"
value={filter.values?.[0] || ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [e.target.value] : null,
})
}
className="w-[200px]"
/>
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">Filters</h3>
<Select value={filterLogic} onValueChange={(value) => onFilterLogicChange(value as "and" | "or")}>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="and">AND</SelectItem>
<SelectItem value="or">OR</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
{filters.map((filter, index) => {
const field = getFieldById(filter.field);
const fieldType = field?.type || "string";
const operators = getFilterOperatorsForType(fieldType as "string" | "number" | "time");
return (
<div
key={index}
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
<Select
value={filter.field}
onValueChange={(value) => {
const newField = getFieldById(value);
const newType = newField?.type || "string";
const newOperators = getFilterOperatorsForType(newType as "string" | "number" | "time");
handleUpdateFilter(index, {
field: value,
operator: newOperators[0] || "equals",
values: null,
});
}}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
{fieldOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(value) =>
handleUpdateFilter(index, {
operator: value as FilterRow["operator"],
})
}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op} value={op}>
{op === "equals" && "equals"}
{op === "notEquals" && "not equals"}
{op === "contains" && "contains"}
{op === "notContains" && "not contains"}
{op === "set" && "is set"}
{op === "notSet" && "is not set"}
{op === "gt" && "greater than"}
{op === "gte" && "greater than or equal"}
{op === "lt" && "less than"}
{op === "lte" && "less than or equal"}
</SelectItem>
))}
</SelectContent>
</Select>
{getValueInput(filter, index)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFilter(index)}
className="h-8 w-8">
<TrashIcon className="h-4 w-4" />
</Button>
</div>
);
})}
<Button type="button" variant="outline" size="sm" onClick={handleAddFilter} className="h-8">
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</div>
);
}

View File

@@ -1,50 +0,0 @@
"use client";
import { cn } from "@/lib/cn";
import { CHART_TYPES } from "../lib/chart-types";
interface ManualChartBuilderProps {
selectedChartType: string;
onChartTypeSelect: (type: string) => void;
}
// Filter out table, map, and scatter charts
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter(
(type) => !["table", "map", "scatter"].includes(type.id)
);
export function ManualChartBuilder({
selectedChartType,
onChartTypeSelect,
}: Omit<ManualChartBuilderProps, "onCreate">) {
return (
<div className="space-y-4">
<h2 className="font-medium text-gray-900">Choose chart type</h2>
<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) => {
const isSelected = selectedChartType === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={cn(
"focus:ring-brand-dark rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
isSelected
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
: "border-gray-200 hover:border-gray-300"
)}>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,155 +0,0 @@
"use client";
import { Plus, TrashIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Input } from "@/modules/ui/components/input";
import { FEEDBACK_FIELDS } from "../lib/schema-definition";
import { CustomMeasure } from "../lib/query-builder";
interface MeasuresPanelProps {
selectedMeasures: string[];
customMeasures: CustomMeasure[];
onMeasuresChange: (measures: string[]) => void;
onCustomMeasuresChange: (measures: CustomMeasure[]) => void;
}
export function MeasuresPanel({
selectedMeasures,
customMeasures,
onMeasuresChange,
onCustomMeasuresChange,
}: MeasuresPanelProps) {
const measureOptions = FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: `${m.label}${m.description ? ` - ${m.description}` : ""}`,
}));
const dimensionOptions = FEEDBACK_FIELDS.dimensions
.filter((d) => d.type === "number")
.map((d) => ({
value: d.id,
label: d.label,
}));
const aggregationOptions = FEEDBACK_FIELDS.customAggregations.map((agg) => ({
value: agg,
label: agg.charAt(0).toUpperCase() + agg.slice(1),
}));
const handleAddCustomMeasure = () => {
onCustomMeasuresChange([
...customMeasures,
{
field: dimensionOptions[0]?.value || "",
aggregation: "avg",
},
]);
};
const handleRemoveCustomMeasure = (index: number) => {
onCustomMeasuresChange(customMeasures.filter((_, i) => i !== index));
};
const handleUpdateCustomMeasure = (index: number, updates: Partial<CustomMeasure>) => {
const updated = [...customMeasures];
updated[index] = { ...updated[index], ...updates };
onCustomMeasuresChange(updated);
};
return (
<div className="space-y-4">
<h3 className="font-medium text-gray-900">Measures</h3>
<div className="space-y-4">
{/* Predefined Measures */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Predefined Measures</label>
<MultiSelect
options={measureOptions}
value={selectedMeasures}
onChange={(selected) => onMeasuresChange(selected)}
placeholder="Select measures..."
/>
</div>
{/* Custom Measures */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Custom Aggregations</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddCustomMeasure}
className="h-8">
<Plus className="h-4 w-4" />
Add Custom Measure
</Button>
</div>
{customMeasures.length > 0 && (
<div className="space-y-2">
{customMeasures.map((measure, index) => (
<div key={index} className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
<Select
value={measure.field}
onValueChange={(value) => handleUpdateCustomMeasure(index, { field: value })}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
{dimensionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={measure.aggregation}
onValueChange={(value) => handleUpdateCustomMeasure(index, { aggregation: value })}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{aggregationOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Alias (optional)"
value={measure.alias || ""}
onChange={(e) => handleUpdateCustomMeasure(index, { alias: e.target.value })}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveCustomMeasure(index)}
className="h-8 w-8">
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,28 +0,0 @@
"use client";
import { CodeIcon } from "lucide-react";
import * as Collapsible from "@radix-ui/react-collapsible";
interface QueryViewerProps {
query: Record<string, any>;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function QueryViewer({ query, isOpen, onOpenChange }: QueryViewerProps) {
return (
<Collapsible.Root open={isOpen} onOpenChange={onOpenChange}>
<Collapsible.CollapsibleContent className="mt-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-2 flex items-center gap-2">
<CodeIcon className="h-4 w-4 text-gray-600" />
<h4 className="text-sm font-semibold text-gray-900">Cube.js Query</h4>
</div>
<pre className="max-h-64 overflow-auto rounded bg-white p-3 text-xs">
{JSON.stringify(query, null, 2)}
</pre>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
}

View File

@@ -1,54 +0,0 @@
"use client";
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
interface SaveChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chartName: string;
onChartNameChange: (name: string) => void;
onSave: () => void;
isSaving: boolean;
}
export function SaveChartDialog({
open,
onOpenChange,
chartName,
onChartNameChange,
onSave,
isSaving,
}: SaveChartDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Chart</DialogTitle>
<DialogDescription>Enter a name for your chart to save it.</DialogDescription>
</DialogHeader>
<DialogBody>
<Input
placeholder="Chart name"
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && chartName.trim() && !isSaving) {
onSave();
}
}}
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={onSave} loading={isSaving} disabled={!chartName.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,224 +0,0 @@
"use client";
import { CalendarIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/modules/ui/components/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Input } from "@/modules/ui/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import Calendar from "react-calendar";
import { format } from "date-fns";
import { FEEDBACK_FIELDS, TIME_GRANULARITIES, DATE_PRESETS } from "../lib/schema-definition";
import { TimeDimensionConfig, getDateRangeFromPreset } from "../lib/query-builder";
import "@/modules/ui/components/date-picker/styles.css";
interface TimeDimensionPanelProps {
timeDimension: TimeDimensionConfig | null;
onTimeDimensionChange: (config: TimeDimensionConfig | null) => void;
}
export function TimeDimensionPanel({
timeDimension,
onTimeDimensionChange,
}: TimeDimensionPanelProps) {
const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">(
timeDimension && typeof timeDimension.dateRange === "string" ? "preset" : "custom"
);
const [customStartDate, setCustomStartDate] = useState<Date | null>(
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[0] : null
);
const [customEndDate, setCustomEndDate] = useState<Date | null>(
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[1] : null
);
const [presetValue, setPresetValue] = useState<string>(
timeDimension && typeof timeDimension.dateRange === "string" ? timeDimension.dateRange : ""
);
const timeFieldOptions = FEEDBACK_FIELDS.dimensions.filter((d) => d.type === "time");
const handleEnableTimeDimension = () => {
if (!timeDimension) {
onTimeDimensionChange({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: "last 30 days",
});
setPresetValue("last 30 days");
setDateRangeType("preset");
}
};
const handleDisableTimeDimension = () => {
onTimeDimensionChange(null);
};
const handleDimensionChange = (dimension: string) => {
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, dimension });
}
};
const handleGranularityChange = (granularity: TimeDimensionConfig["granularity"]) => {
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, granularity });
}
};
const handlePresetChange = (preset: string) => {
setPresetValue(preset);
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, dateRange: preset });
}
};
const handleCustomDateChange = () => {
if (customStartDate && customEndDate && timeDimension) {
onTimeDimensionChange({
...timeDimension,
dateRange: [customStartDate, customEndDate],
});
}
};
if (!timeDimension) {
return (
<div className="space-y-4">
<h3 className="font-medium text-gray-900">Time Dimension</h3>
<div>
<Button type="button" variant="outline" onClick={handleEnableTimeDimension}>
Enable Time Dimension
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">Time Dimension</h3>
<Button type="button" variant="ghost" size="sm" onClick={handleDisableTimeDimension}>
Disable
</Button>
</div>
<div className="space-y-3">
{/* Field Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Field</label>
<Select value={timeDimension.dimension} onValueChange={handleDimensionChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeFieldOptions.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Granularity Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Granularity</label>
<Select
value={timeDimension.granularity}
onValueChange={(value) => handleGranularityChange(value as TimeDimensionConfig["granularity"])}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_GRANULARITIES.map((gran) => (
<SelectItem key={gran} value={gran}>
{gran.charAt(0).toUpperCase() + gran.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Date Range */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Date Range</label>
<div className="space-y-2">
<Select value={dateRangeType} onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="preset">Preset</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{dateRangeType === "preset" ? (
<Select value={presetValue} onValueChange={handlePresetChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select preset" />
</SelectTrigger>
<SelectContent>
{DATE_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="grid grid-cols-2 gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{customStartDate ? format(customStartDate, "MMM dd, yyyy") : "Start date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
onChange={(date: Date) => {
setCustomStartDate(date);
if (date && customEndDate) {
handleCustomDateChange();
}
}}
value={customStartDate || undefined}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{customEndDate ? format(customEndDate, "MMM dd, yyyy") : "End date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
onChange={(date: Date) => {
setCustomEndDate(date);
if (customStartDate && date) {
handleCustomDateChange();
}
}}
value={customEndDate || undefined}
minDate={customStartDate || undefined}
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +0,0 @@
export { ChartRenderer } from "./ChartRenderer";
export { QueryViewer } from "./QueryViewer";
export { DataViewer } from "./DataViewer";
export { AIQuerySection } from "./AIQuerySection";
export { ManualChartBuilder } from "./ManualChartBuilder";
export { ChartPreview } from "./ChartPreview";
export { SaveChartDialog } from "./SaveChartDialog";
export { AddToDashboardDialog } from "./AddToDashboardDialog";
export { ConfigureChartDialog } from "./ConfigureChartDialog";
export { ChartBuilderClient } from "./ChartBuilderClient";

View File

@@ -1,21 +0,0 @@
import {
ActivityIcon,
AreaChartIcon,
BarChart3Icon,
LineChartIcon,
MapIcon,
PieChartIcon,
ScatterChart,
TableIcon,
} from "lucide-react";
export const CHART_TYPES = [
{ id: "area", name: "Area Chart", icon: AreaChartIcon },
{ id: "bar", name: "Bar Chart", icon: BarChart3Icon },
{ id: "line", name: "Line Chart", icon: LineChartIcon },
{ id: "pie", name: "Pie Chart", icon: PieChartIcon },
{ id: "table", name: "Table", icon: TableIcon },
{ id: "big_number", name: "Big Number", icon: ActivityIcon },
{ id: "scatter", name: "Scatter Plot", icon: ScatterChart },
{ id: "map", name: "World Map", icon: MapIcon },
] as const;

View File

@@ -1,35 +0,0 @@
// Chart type mapping from API to database
export const mapChartType = (
apiType: string
): "area" | "bar" | "line" | "pie" | "big_number" | "big_number_total" | "table" | "funnel" | "map" => {
const mapping: Record<
string,
"area" | "bar" | "line" | "pie" | "big_number" | "big_number_total" | "table" | "funnel" | "map"
> = {
bar: "bar",
line: "line",
area: "area",
pie: "pie",
donut: "pie",
kpi: "big_number",
};
return mapping[apiType] || "bar";
};
// Reverse mapping from database chart type to API chart type
export const mapDatabaseChartTypeToApi = (
dbType: string
): "bar" | "line" | "donut" | "kpi" | "area" | "pie" => {
const mapping: Record<string, "bar" | "line" | "donut" | "kpi" | "area" | "pie"> = {
bar: "bar",
line: "line",
area: "area",
pie: "pie",
big_number: "kpi",
big_number_total: "kpi",
table: "bar", // Default fallback
funnel: "bar", // Default fallback
map: "bar", // Default fallback
};
return mapping[dbType] || "bar";
};

View File

@@ -1,185 +0,0 @@
/**
* Query builder utility to construct Cube.js queries from chart builder state
*/
import { CubeQuery, TimeDimension, Filter } from "@/app/api/analytics/_lib/types";
export interface CustomMeasure {
field: string; // e.g., "FeedbackRecords.npsValue"
aggregation: string; // e.g., "avg", "sum", "countDistinct"
alias?: string; // optional display name
}
export interface FilterRow {
field: string;
operator: Filter["operator"];
values: string[] | number[] | null;
}
export interface TimeDimensionConfig {
dimension: string;
granularity: "hour" | "day" | "week" | "month" | "quarter" | "year";
dateRange: string | [Date, Date]; // "last 7 days" or [startDate, endDate]
}
export interface ChartBuilderState {
chartType: string;
selectedMeasures: string[];
customMeasures: CustomMeasure[];
selectedDimensions: string[];
filters: FilterRow[];
filterLogic: "and" | "or";
timeDimension: TimeDimensionConfig | null;
limit?: number;
orderBy?: { field: string; direction: "asc" | "desc" };
}
/**
* Build a Cube.js query from chart builder state
*/
export function buildCubeQuery(config: ChartBuilderState): CubeQuery {
const query: CubeQuery = {
measures: [
...config.selectedMeasures,
// Custom measures would need to be handled differently in Cube.js
// For now, we'll just include the predefined measures
],
};
if (config.selectedDimensions.length > 0) {
query.dimensions = config.selectedDimensions;
}
if (config.timeDimension) {
const timeDim: TimeDimension = {
dimension: config.timeDimension.dimension,
granularity: config.timeDimension.granularity,
};
// Handle date range
if (typeof config.timeDimension.dateRange === "string") {
timeDim.dateRange = config.timeDimension.dateRange;
} else if (Array.isArray(config.timeDimension.dateRange)) {
// Convert Date objects to ISO strings (Cube.js expects YYYY-MM-DD format or ISO strings)
const [startDate, endDate] = config.timeDimension.dateRange;
// Format as YYYY-MM-DD for better compatibility
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
}
query.timeDimensions = [timeDim];
}
if (config.filters.length > 0) {
query.filters = config.filters.map((f) => {
const filter: Filter = {
member: f.field,
operator: f.operator,
};
// Only include values if operator requires them
if (f.operator !== "set" && f.operator !== "notSet" && f.values) {
filter.values = f.values.map((v) => String(v));
}
return filter;
});
}
return query;
}
/**
* Parse a Cube.js query back into ChartBuilderState
*/
export function parseQueryToState(query: CubeQuery, chartType?: string): Partial<ChartBuilderState> {
const state: Partial<ChartBuilderState> = {
chartType: chartType || "",
selectedMeasures: query.measures || [],
customMeasures: [],
selectedDimensions: query.dimensions || [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
// Parse filters
if (query.filters && query.filters.length > 0) {
state.filters = query.filters.map((f) => ({
field: f.member,
operator: f.operator,
values: f.values || null,
}));
}
// Parse time dimensions
if (query.timeDimensions && query.timeDimensions.length > 0) {
const timeDim = query.timeDimensions[0];
state.timeDimension = {
dimension: timeDim.dimension,
granularity: (timeDim.granularity || "day") as TimeDimensionConfig["granularity"],
dateRange: timeDim.dateRange || "last 30 days",
};
}
return state;
}
/**
* Convert date preset string to date range
*/
export function getDateRangeFromPreset(preset: string): [Date, Date] | null {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
switch (preset) {
case "today": {
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
return [today, tomorrow];
}
case "yesterday": {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
return [yesterday, today];
}
case "last 7 days": {
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return [sevenDaysAgo, today];
}
case "last 30 days": {
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
return [thirtyDaysAgo, today];
}
case "this month": {
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return [firstDay, lastDay];
}
case "last month": {
const firstDayLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const firstDayThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return [firstDayLastMonth, firstDayThisMonth];
}
case "this quarter": {
const quarter = Math.floor(now.getMonth() / 3);
const firstDay = new Date(now.getFullYear(), quarter * 3, 1);
const lastDay = new Date(now.getFullYear(), (quarter + 1) * 3, 1);
return [firstDay, lastDay];
}
case "this year": {
const firstDay = new Date(now.getFullYear(), 0, 1);
const lastDay = new Date(now.getFullYear() + 1, 0, 1);
return [firstDay, lastDay];
}
default:
return null;
}
}

View File

@@ -1,171 +0,0 @@
/**
* Schema definitions for FeedbackRecords fields
* Used by the advanced chart builder to provide field metadata and operators
*/
export interface FieldDefinition {
id: string;
label: string;
type: "string" | "number" | "time";
description?: string;
}
export interface MeasureDefinition {
id: string;
label: string;
type: "count" | "number";
description?: string;
}
export const FEEDBACK_FIELDS = {
dimensions: [
{
id: "FeedbackRecords.sentiment",
label: "Sentiment",
type: "string",
description: "Sentiment extracted from feedback",
},
{
id: "FeedbackRecords.sourceType",
label: "Source Type",
type: "string",
description: "Source type of the feedback (e.g., nps_campaign, survey)",
},
{
id: "FeedbackRecords.sourceName",
label: "Source Name",
type: "string",
description: "Human-readable name of the source",
},
{
id: "FeedbackRecords.fieldType",
label: "Field Type",
type: "string",
description: "Type of feedback field (e.g., nps, text, rating)",
},
{
id: "FeedbackRecords.emotion",
label: "Emotion",
type: "string",
description: "Emotion extracted from metadata JSONB field",
},
{
id: "FeedbackRecords.userIdentifier",
label: "User Identifier",
type: "string",
description: "Identifier of the user who provided feedback",
},
{
id: "FeedbackRecords.responseId",
label: "Response ID",
type: "string",
description: "Unique identifier linking related feedback records",
},
{
id: "FeedbackRecords.npsValue",
label: "NPS Value",
type: "number",
description: "Raw NPS score value (0-10)",
},
{
id: "FeedbackRecords.collectedAt",
label: "Collected At",
type: "time",
description: "Timestamp when the feedback was collected",
},
{
id: "TopicsUnnested.topic",
label: "Topic",
type: "string",
description: "Individual topic from the topics array",
},
] as FieldDefinition[],
measures: [
{
id: "FeedbackRecords.count",
label: "Count",
type: "count",
description: "Total number of feedback responses",
},
{
id: "FeedbackRecords.promoterCount",
label: "Promoter Count",
type: "count",
description: "Number of promoters (NPS score 9-10)",
},
{
id: "FeedbackRecords.detractorCount",
label: "Detractor Count",
type: "count",
description: "Number of detractors (NPS score 0-6)",
},
{
id: "FeedbackRecords.passiveCount",
label: "Passive Count",
type: "count",
description: "Number of passives (NPS score 7-8)",
},
{
id: "FeedbackRecords.npsScore",
label: "NPS Score",
type: "number",
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
},
{
id: "FeedbackRecords.averageScore",
label: "Average Score",
type: "number",
description: "Average NPS score",
},
] as MeasureDefinition[],
customAggregations: ["count", "countDistinct", "sum", "avg", "min", "max"],
};
export type FilterOperator =
| "equals"
| "notEquals"
| "contains"
| "notContains"
| "set"
| "notSet"
| "gt"
| "gte"
| "lt"
| "lte";
export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
};
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
export type TimeGranularity = (typeof TIME_GRANULARITIES)[number];
export const DATE_PRESETS = [
{ label: "Today", value: "today" },
{ label: "Yesterday", value: "yesterday" },
{ label: "Last 7 days", value: "last 7 days" },
{ label: "Last 30 days", value: "last 30 days" },
{ label: "This month", value: "this month" },
{ label: "Last month", value: "last month" },
{ label: "This quarter", value: "this quarter" },
{ label: "This year", value: "this year" },
] as const;
/**
* Get filter operators for a given field type
*/
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
}
/**
* Get field definition by ID
*/
export function getFieldById(id: string): FieldDefinition | MeasureDefinition | undefined {
const dimension = FEEDBACK_FIELDS.dimensions.find((d) => d.id === id);
if (dimension) return dimension;
return FEEDBACK_FIELDS.measures.find((m) => m.id === id);
}

View File

@@ -1,126 +0,0 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { TChart } from "../../types/analysis";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
interface ChartDropdownMenuProps {
environmentId: string;
chart: TChart;
disabled?: boolean;
deleteChart: (chartId: string) => void;
onEdit?: () => void;
}
export const ChartDropdownMenu = ({
environmentId,
chart,
disabled,
deleteChart,
onEdit,
}: ChartDropdownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDeleteChart = async (chartId: string) => {
setLoading(true);
try {
// TODO: Implement deleteChartAction
// await deleteChartAction({ chartId });
deleteChart(chartId);
toast.success("Chart deleted successfully");
} catch (error) {
toast.error("Error deleting chart");
} finally {
setLoading(false);
}
};
return (
<div
id={`${chart.name.toLowerCase().split(" ").join("-")}-chart-actions`}
data-testid="chart-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}
onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open options</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onEdit?.();
}}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
// TODO: Implement duplicate functionality
toast.success("Duplicate functionality coming soon");
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Chart"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteChart(chart.id)}
text="Are you sure you want to delete this chart? This action cannot be undone."
isDeleting={loading}
/>
</div>
);
};

View File

@@ -1,133 +0,0 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { useState } from "react";
import {
ActivityIcon,
AreaChartIcon,
BarChart3Icon,
LineChartIcon,
MapIcon,
PieChartIcon,
ScatterChart,
TableIcon,
} from "lucide-react";
import { TChart } from "../../types/analysis";
import { ChartDropdownMenu } from "./ChartDropdownMenu";
import { CreateChartDialog } from "./CreateChartDialog";
interface ChartsListClientProps {
charts: TChart[];
dashboards: any[];
environmentId: string;
}
const CHART_TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
area: AreaChartIcon,
bar: BarChart3Icon,
line: LineChartIcon,
pie: PieChartIcon,
table: TableIcon,
big_number: ActivityIcon,
big_number_total: ActivityIcon,
scatter: ScatterChart,
map: MapIcon,
};
export function ChartsListClient({ charts: initialCharts, dashboards, environmentId }: ChartsListClientProps) {
const [charts, setCharts] = useState(initialCharts);
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const filteredCharts = charts;
const deleteChart = (chartId: string) => {
setCharts(charts.filter((c) => c.id !== chartId));
};
const getChartIcon = (type: string) => {
const IconComponent = CHART_TYPE_ICONS[type] || BarChart3Icon;
return <IconComponent className="h-5 w-5" />;
};
const handleChartClick = (chartId: string) => {
setEditingChartId(chartId);
setIsEditDialogOpen(true);
};
const handleEditSuccess = () => {
// Refresh charts list if needed
setIsEditDialogOpen(false);
setEditingChartId(undefined);
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">Title</div>
<div className="col-span-1 hidden text-center sm:block">Created By</div>
<div className="col-span-1 hidden text-center sm:block">Created</div>
<div className="col-span-1 hidden text-center sm:block">Updated</div>
<div className="col-span-1"></div>
</div>
{filteredCharts.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">No charts found.</p>
) : (
<>
{filteredCharts.map((chart) => (
<div
key={chart.id}
onClick={() => handleChartClick(chart.id)}
className="grid h-12 w-full cursor-pointer grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
{getChartIcon(chart.type)}
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{chart.createdByName || "-"}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{format(new Date(chart.createdAt), "do 'of' MMMM, yyyy")}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(new Date(chart.updatedAt), {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div
className="col-span-1 my-auto flex items-center justify-end pr-6"
onClick={(e) => e.stopPropagation()}>
<ChartDropdownMenu
environmentId={environmentId}
chart={chart}
deleteChart={deleteChart}
onEdit={() => {
setEditingChartId(chart.id);
setIsEditDialogOpen(true);
}}
/>
</div>
</div>
))}
</>
)}
<CreateChartDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
environmentId={environmentId}
chartId={editingChartId}
onSuccess={handleEditSuccess}
/>
</div>
);
}

View File

@@ -1,28 +0,0 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/modules/ui/components/button";
import { CreateChartDialog } from "./CreateChartDialog";
interface CreateChartButtonProps {
environmentId: string;
}
export function CreateChartButton({ environmentId }: CreateChartButtonProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<>
<Button onClick={() => setIsDialogOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
Chart
</Button>
<CreateChartDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
environmentId={environmentId}
/>
</>
);
}

View File

@@ -1,431 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
addChartToDashboardAction,
createChartAction,
executeQueryAction,
getChartAction,
getDashboardsAction,
updateChartAction,
} from "../../actions";
import { PlusIcon, SaveIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import { mapChartType, mapDatabaseChartTypeToApi } from "../../chart-builder/lib/chart-utils";
import { AIQuerySection } from "../../chart-builder/components/AIQuerySection";
import { AddToDashboardDialog } from "../../chart-builder/components/AddToDashboardDialog";
import { AdvancedChartBuilder } from "../../chart-builder/components/AdvancedChartBuilder";
import { ChartPreview } from "../../chart-builder/components/ChartPreview";
import { ManualChartBuilder } from "../../chart-builder/components/ManualChartBuilder";
import { SaveChartDialog } from "../../chart-builder/components/SaveChartDialog";
interface CreateChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
onSuccess?: () => void;
}
export function CreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
onSuccess,
}: CreateChartDialogProps) {
const [selectedChartType, setSelectedChartType] = useState<string>("");
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
// Determine if we should show AdvancedChartBuilder
const shouldShowAdvancedBuilder = !!selectedChartType || !!chartData;
useEffect(() => {
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (result?.data) {
setDashboards(result.data);
} else if (result?.serverError) {
toast.error(result.serverError);
}
});
}
}, [isAddToDashboardDialogOpen, environmentId]);
useEffect(() => {
if (open && chartId) {
setIsLoadingChart(true);
getChartAction({ environmentId, chartId })
.then(async (result) => {
if (result?.data) {
const chart = result.data;
setChartName(chart.name);
// Execute the chart's query to get the data
const queryResult = await executeQueryAction({
environmentId,
query: chart.query as any,
});
if (queryResult?.error || queryResult?.serverError) {
toast.error(queryResult.error || queryResult.serverError || "Failed to load chart data");
setIsLoadingChart(false);
return;
}
if (queryResult?.data?.data) {
// Format as AnalyticsResponse
const chartData: AnalyticsResponse = {
query: chart.query as any,
chartType: mapDatabaseChartTypeToApi(chart.type),
data: Array.isArray(queryResult.data.data) ? queryResult.data.data : [],
};
setChartData(chartData);
setCurrentChartId(chart.id);
} else {
toast.error("No data returned for chart");
}
} else if (result?.serverError) {
toast.error(result.serverError);
}
setIsLoadingChart(false);
})
.catch((error: any) => {
toast.error(error.message || "Failed to load chart");
setIsLoadingChart(false);
});
} else if (open && !chartId) {
// Reset state for new chart
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
}
}, [open, chartId, environmentId]);
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
// Set chart type so AdvancedChartBuilder shows with the AI-generated chart type
if (data.chartType) {
setSelectedChartType(data.chartType);
}
};
const handleSaveChart = async () => {
if (!chartData || !chartName.trim()) {
toast.error("Please enter a chart name");
return;
}
setIsSaving(true);
try {
// If we have a currentChartId, update the existing chart; otherwise create a new one
if (currentChartId) {
const result = await updateChartAction({
environmentId,
chartId: currentChartId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to update chart");
return;
}
toast.success("Chart updated successfully!");
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
} else {
const result = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to save chart");
return;
}
setCurrentChartId(result.data.id);
toast.success("Chart saved successfully!");
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
}
} catch (error: any) {
toast.error(error.message || "Failed to save chart");
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error("Please select a dashboard");
return;
}
setIsSaving(true);
try {
let chartIdToUse = currentChartId;
// If we don't have a chartId (creating new chart), create it first
if (!chartIdToUse) {
if (!chartName.trim()) {
toast.error("Please enter a chart name");
setIsSaving(false);
return;
}
const chartResult = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!chartResult?.data) {
toast.error(chartResult?.serverError || "Failed to save chart");
setIsSaving(false);
return;
}
chartIdToUse = chartResult.data.id;
setCurrentChartId(chartIdToUse);
}
// Add the chart (existing or newly created) to the dashboard
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartIdToUse,
dashboardId: selectedDashboardId,
title: chartName.trim(),
layout: { x: 0, y: 0, w: 4, h: 3 },
});
if (!widgetResult?.data) {
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
return;
}
toast.success("Chart added to dashboard!");
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
onSuccess?.();
} catch (error: any) {
toast.error(error.message || "Failed to add chart to dashboard");
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
onOpenChange(false);
}
};
// If loading an existing chart, show loading state
if (chartId && isLoadingChart) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-[90vw] max-h-[90vh] overflow-y-auto">
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
</DialogContent>
</Dialog>
);
}
// If viewing an existing chart, show only the chart preview
if (chartId && chartData) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-7xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Chart</DialogTitle>
<DialogDescription>View and edit your chart configuration.</DialogDescription>
</DialogHeader>
<DialogBody>
<ChartPreview chartData={chartData} />
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
Add to Dashboard
</Button>
<Button onClick={() => setIsSaveDialogOpen(true)} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
Save Chart
</Button>
</DialogFooter>
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
<DialogHeader>
<DialogTitle>{chartId ? "Edit Chart" : "Create Chart"}</DialogTitle>
<DialogDescription>
{chartId
? "View and edit your chart configuration."
: "Use AI to generate a chart or build one manually."}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4">
{/* AI Query Section */}
<AIQuerySection onChartGenerated={handleChartGenerated} />
{/* OR Separator */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-2 text-sm text-gray-500">OR</span>
</div>
</div>
{/* Chart Type Selection */}
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={setSelectedChartType}
/>
{/* Advanced Builder - shown when chart type selected OR AI chart generated */}
{shouldShowAdvancedBuilder && (
<AdvancedChartBuilder
environmentId={environmentId}
initialChartType={selectedChartType || chartData?.chartType || ""}
initialQuery={chartData?.query}
hidePreview={true}
onChartGenerated={(data) => {
setChartData(data);
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
// Update selectedChartType when chart type changes in AdvancedChartBuilder
if (data.chartType) {
setSelectedChartType(data.chartType);
}
}}
onSave={(chartId) => {
setCurrentChartId(chartId);
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
}}
onAddToDashboard={(chartId, dashboardId) => {
setCurrentChartId(chartId);
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
onSuccess?.();
}}
/>
)}
{/* Single Chart Preview - shown when chartData exists */}
{chartData && <ChartPreview chartData={chartData} />}
</div>
</DialogBody>
{chartData && (
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
Add to Dashboard
</Button>
<Button onClick={() => setIsSaveDialogOpen(true)} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
Save Chart
</Button>
</DialogFooter>
)}
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,13 +0,0 @@
import { getCharts, getDashboards } from "../lib/data";
import { ChartsListClient } from "./components/ChartsListClient";
export default async function ChartsListPage({
params,
}: {
params: Promise<{ environmentId: string }>;
}) {
const { environmentId } = await params;
const [charts, dashboards] = await Promise.all([getCharts(environmentId), getDashboards(environmentId)]);
return <ChartsListClient charts={charts} dashboards={dashboards} environmentId={environmentId} />;
}

View File

@@ -1,42 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { use } from "react";
import { CreateChartButton } from "../charts/components/CreateChartButton";
import { CreateDashboardButton } from "../dashboards/components/CreateDashboardButton";
import { AnalysisPageLayout } from "./analysis-page-layout";
interface AnalysisLayoutClientProps {
children: React.ReactNode;
params: Promise<{ environmentId: string }>;
}
export function AnalysisLayoutClient({ children, params }: AnalysisLayoutClientProps) {
const pathname = usePathname();
const { environmentId } = use(params);
// Determine active tab based on pathname
let activeId = "dashboards"; // default
if (pathname?.includes("/charts")) {
activeId = "charts";
} else if (pathname?.includes("/dashboards") || pathname?.includes("/dashboard/")) {
activeId = "dashboards";
}
// Show CTA button based on current page
const isDashboardsPage = pathname?.includes("/dashboards") && !pathname?.includes("/dashboard/");
const isChartsPage = pathname?.includes("/charts");
let cta;
if (isDashboardsPage) {
cta = <CreateDashboardButton environmentId={environmentId} />;
} else if (isChartsPage) {
cta = <CreateChartButton environmentId={environmentId} />;
}
return (
<AnalysisPageLayout pageTitle="Analysis" activeId={activeId} environmentId={environmentId} cta={cta}>
{children}
</AnalysisPageLayout>
);
}

View File

@@ -1,29 +0,0 @@
import { ReactNode } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
interface AnalysisPageLayoutProps {
pageTitle: string;
activeId: string;
environmentId: string;
cta?: ReactNode;
children: ReactNode;
}
export function AnalysisPageLayout({
pageTitle,
activeId,
environmentId,
cta,
children,
}: AnalysisPageLayoutProps) {
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} cta={cta}>
<AnalysisSecondaryNavigation activeId={activeId} environmentId={environmentId} />
</PageHeader>
{children}
</PageContentWrapper>
);
}

View File

@@ -1,26 +0,0 @@
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface AnalysisSecondaryNavigationProps {
activeId: string;
environmentId: string;
}
export function AnalysisSecondaryNavigation({
activeId,
environmentId,
}: AnalysisSecondaryNavigationProps) {
const navigation = [
{
id: "dashboards",
label: "Dashboards",
href: `/environments/${environmentId}/analysis/dashboards`,
},
{
id: "charts",
label: "Charts",
href: `/environments/${environmentId}/analysis/charts`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} />;
}

View File

@@ -1,106 +0,0 @@
"use client";
import { CopyIcon, PencilIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDashboard } from "../../../types/analysis";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
import { EditDashboardDialog } from "./EditDashboardDialog";
interface DashboardControlBarProps {
environmentId: string;
dashboard: TDashboard;
onDashboardUpdate?: () => void;
}
export const DashboardControlBar = ({
environmentId,
dashboard,
onDashboardUpdate,
}: DashboardControlBarProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const handleDeleteDashboard = async () => {
setIsDeleting(true);
try {
// TODO: Implement deleteDashboardAction when available
// const result = await deleteDashboardAction({ environmentId, dashboardId: dashboard.id });
// if (result?.data) {
// router.push(`/environments/${environmentId}/analysis/dashboards`);
// toast.success("Dashboard deleted successfully");
// } else {
// toast.error(result?.serverError || "Failed to delete dashboard");
// }
toast.error("Delete functionality coming soon");
} catch (error: any) {
toast.error(error.message || "Failed to delete dashboard");
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
}
};
const handleDuplicate = async () => {
// TODO: Implement duplicate functionality
toast.success("Duplicate functionality coming soon");
};
const iconActions = [
{
icon: PencilIcon,
tooltip: t("common.edit"),
onClick: () => {
setIsEditDialogOpen(true);
},
isVisible: true,
},
{
icon: CopyIcon,
tooltip: t("common.duplicate"),
onClick: handleDuplicate,
isVisible: true,
},
{
icon: TrashIcon,
tooltip: t("common.delete"),
onClick: () => {
setDeleteDialogOpen(true);
},
isVisible: true,
},
];
return (
<>
<IconBar actions={iconActions} />
<DeleteDialog
deleteWhat="Dashboard"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={handleDeleteDashboard}
text="Are you sure you want to delete this dashboard? This action cannot be undone."
isDeleting={isDeleting}
/>
<EditDashboardDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
dashboardId={dashboard.id}
environmentId={environmentId}
initialName={dashboard.name}
initialDescription={dashboard.description}
onSuccess={() => {
setIsEditDialogOpen(false);
onDashboardUpdate?.();
router.refresh();
}}
/>
</>
);
};

View File

@@ -1,82 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { TDashboard } from "../../../types/analysis";
import { CreateChartButton } from "../../../charts/components/CreateChartButton";
import { DashboardWidget } from "./DashboardWidget";
import { DashboardControlBar } from "./DashboardControlBar";
interface DashboardDetailClientProps {
dashboard: TDashboard;
environmentId: string;
}
export function DashboardDetailClient({ dashboard: initialDashboard, environmentId }: DashboardDetailClientProps) {
const router = useRouter();
const [dashboard] = useState(initialDashboard);
const isEmpty = dashboard.widgets.length === 0;
const handleDashboardUpdate = () => {
router.refresh();
};
// Calculate grid column span based on widget layout width
const getColSpan = (w: number) => {
// Assuming w is in a 12-column grid system
// Map widget width to Tailwind col-span classes
if (w <= 2) return "col-span-12 md:col-span-2";
if (w <= 3) return "col-span-12 md:col-span-3";
if (w <= 4) return "col-span-12 md:col-span-4";
if (w <= 6) return "col-span-12 md:col-span-6";
if (w <= 8) return "col-span-12 md:col-span-8";
if (w <= 9) return "col-span-12 md:col-span-9";
return "col-span-12";
};
return (
<PageContentWrapper>
<GoBackButton url={`/environments/${environmentId}/analysis/dashboards`} />
<PageHeader
pageTitle={dashboard.name}
cta={
<DashboardControlBar
environmentId={environmentId}
dashboard={dashboard}
onDashboardUpdate={handleDashboardUpdate}
/>
}>
{dashboard.description && (
<p className="mt-2 text-sm text-gray-500">{dashboard.description}</p>
)}
</PageHeader>
<section className="pt-6 pb-24">
{isEmpty ? (
// Empty State
<div className="flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/50">
<div className="mb-4 rounded-full bg-gray-100 p-4">
<div className="h-12 w-12 rounded-md bg-gray-300 opacity-20" />
</div>
<h3 className="text-lg font-medium text-gray-900">No Data</h3>
<p className="mt-2 max-w-sm text-center text-gray-500">
There is currently no information to display. Add charts to build your dashboard.
</p>
<CreateChartButton environmentId={environmentId} />
</div>
) : (
// Grid Layout - Render widgets dynamically
<div className="grid grid-cols-12 gap-6">
{dashboard.widgets.map((widget) => (
<div key={widget.id} className={getColSpan(widget.layout.w)}>
<DashboardWidget widget={widget} environmentId={environmentId} />
</div>
))}
</div>
)}
</section>
</PageContentWrapper>
);
}

View File

@@ -1,154 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { MoreHorizontalIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { executeQueryAction } from "../../../actions";
import { ChartRenderer } from "../../../chart-builder/components/ChartRenderer";
import { TDashboardWidget } from "../../../types/analysis";
interface DashboardWidgetProps {
widget: TDashboardWidget;
environmentId: string;
}
export function DashboardWidget({ widget, environmentId }: DashboardWidgetProps) {
const [chartData, setChartData] = useState<Record<string, any>[] | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (widget.type === "chart" && widget.chart) {
setIsLoading(true);
setError(null);
executeQueryAction({
environmentId,
query: widget.chart.query,
})
.then((result) => {
if (result?.serverError || result?.error) {
setError(result.serverError || result.error || "Failed to load chart data");
setChartData(null);
} else if (result?.data?.data) {
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setError(null);
} else {
setError("No data returned");
setChartData(null);
}
})
.catch((err: any) => {
setError(err.message || "Failed to load chart data");
setChartData(null);
})
.finally(() => {
setIsLoading(false);
});
}
}, [widget, environmentId]);
const renderContent = () => {
if (widget.type === "chart") {
if (!widget.chart) {
return (
<div className="flex h-full items-center justify-center text-gray-500">
Chart not found
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (error) {
return (
<div className="flex h-full w-full flex-col items-start justify-center rounded-md border border-red-100 bg-red-50 p-4">
<div className="mb-1 flex items-center gap-2 font-semibold text-red-700">
<div className="rounded-full bg-red-600 p-0.5">
<span className="block h-3 w-3 text-center text-[10px] leading-3 text-white"></span>
</div>
Data error
</div>
<p className="text-xs text-red-600">{error}</p>
</div>
);
}
if (!chartData || chartData.length === 0) {
return (
<div className="flex h-full items-center justify-center text-gray-500">
No data available
</div>
);
}
return <ChartRenderer chartType={widget.chart.type} data={chartData} />;
}
if (widget.type === "markdown") {
return (
<div className="prose prose-sm max-w-none">
{/* TODO: Render markdown content */}
<p className="text-gray-500">Markdown content not yet implemented</p>
</div>
);
}
if (widget.type === "header") {
return (
<div className="flex h-full items-center">
<h2 className="text-2xl font-semibold text-gray-900">{widget.title || "Header"}</h2>
</div>
);
}
if (widget.type === "divider") {
return <div className="h-full w-full border-t border-gray-200" />;
}
return null;
};
return (
<div className="flex h-full flex-col rounded-sm border border-gray-200 bg-white shadow-sm ring-1 ring-black/5">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-2">
<h3 className="text-sm font-semibold text-gray-800">
{widget.title || widget.chart?.name || "Widget"}
</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-gray-400 hover:text-gray-600">
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Force refresh</DropdownMenuItem>
<DropdownMenuItem>View as table</DropdownMenuItem>
<DropdownMenuItem>Maximize</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Body */}
<div className="relative min-h-[300px] flex-1 p-4">{renderContent()}</div>
</div>
);
}

View File

@@ -1,126 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "react-hot-toast";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { updateDashboardAction } from "../../../actions";
interface EditDashboardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dashboardId: string;
environmentId: string;
initialName: string;
initialDescription?: string;
onSuccess: () => void;
}
export function EditDashboardDialog({
open,
onOpenChange,
dashboardId,
environmentId,
initialName,
initialDescription,
onSuccess,
}: EditDashboardDialogProps) {
const [dashboardName, setDashboardName] = useState(initialName);
const [dashboardDescription, setDashboardDescription] = useState(initialDescription || "");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (open) {
setDashboardName(initialName);
setDashboardDescription(initialDescription || "");
}
}, [open, initialName, initialDescription]);
const handleSave = async () => {
if (!dashboardName.trim()) {
toast.error("Please enter a dashboard name");
return;
}
setIsSaving(true);
try {
const result = await updateDashboardAction({
environmentId,
dashboardId,
name: dashboardName.trim(),
description: dashboardDescription.trim() || null,
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to update dashboard");
return;
}
toast.success("Dashboard updated successfully!");
onSuccess();
onOpenChange(false);
} catch (error: any) {
toast.error(error.message || "Failed to update dashboard");
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Dashboard</DialogTitle>
<DialogDescription>Update dashboard name and description.</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="space-y-2">
<label htmlFor="edit-dashboard-name" className="text-sm font-medium text-gray-900">
Dashboard Name
</label>
<Input
id="edit-dashboard-name"
placeholder="Dashboard name"
value={dashboardName}
onChange={(e) => setDashboardName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && dashboardName.trim() && !isSaving) {
handleSave();
}
}}
autoFocus
/>
</div>
<div className="space-y-2">
<label htmlFor="edit-dashboard-description" className="text-sm font-medium text-gray-900">
Description (Optional)
</label>
<Input
id="edit-dashboard-description"
placeholder="Dashboard description"
value={dashboardDescription}
onChange={(e) => setDashboardDescription(e.target.value)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={handleSave} loading={isSaving} disabled={!dashboardName.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,8 +0,0 @@
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// This layout bypasses the analysis layout, allowing the dashboard page to have its own layout
return children;
}

View File

@@ -1,18 +0,0 @@
import { notFound } from "next/navigation";
import { getDashboard } from "../../lib/data";
import { DashboardDetailClient } from "./components/DashboardDetailClient";
export default async function DashboardPage({
params,
}: {
params: Promise<{ environmentId: string; dashboardId: string }>;
}) {
const { environmentId, dashboardId } = await params;
const dashboard = await getDashboard(environmentId, dashboardId);
if (!dashboard) {
return notFound();
}
return <DashboardDetailClient dashboard={dashboard} environmentId={environmentId} />;
}

View File

@@ -1,76 +0,0 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/modules/ui/components/button";
import { createDashboardAction } from "../../actions";
import { CreateDashboardDialog } from "./CreateDashboardDialog";
interface CreateDashboardButtonProps {
environmentId: string;
}
export function CreateDashboardButton({ environmentId }: CreateDashboardButtonProps) {
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [dashboardName, setDashboardName] = useState("");
const [dashboardDescription, setDashboardDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
const handleCreateDashboard = () => {
setIsCreateDialogOpen(true);
};
const handleCreate = async () => {
if (!dashboardName.trim()) {
toast.error("Please enter a dashboard name");
return;
}
setIsCreating(true);
try {
const result = await createDashboardAction({
environmentId,
name: dashboardName.trim(),
description: dashboardDescription.trim() || undefined,
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to create dashboard");
return;
}
toast.success("Dashboard created successfully!");
setIsCreateDialogOpen(false);
setDashboardName("");
setDashboardDescription("");
// Navigate to the new dashboard
router.push(`/environments/${environmentId}/analysis/dashboard/${result.data.id}`);
} catch (error: any) {
toast.error(error.message || "Failed to create dashboard");
} finally {
setIsCreating(false);
}
};
return (
<>
<Button onClick={handleCreateDashboard}>
<PlusIcon className="mr-2 h-4 w-4" />
Create Dashboard
</Button>
<CreateDashboardDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
dashboardName={dashboardName}
onDashboardNameChange={setDashboardName}
dashboardDescription={dashboardDescription}
onDashboardDescriptionChange={setDashboardDescription}
onCreate={handleCreate}
isCreating={isCreating}
/>
</>
);
}

View File

@@ -1,84 +0,0 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
interface CreateDashboardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dashboardName: string;
onDashboardNameChange: (name: string) => void;
dashboardDescription: string;
onDashboardDescriptionChange: (description: string) => void;
onCreate: () => void;
isCreating: boolean;
}
export function CreateDashboardDialog({
open,
onOpenChange,
dashboardName,
onDashboardNameChange,
dashboardDescription,
onDashboardDescriptionChange,
onCreate,
isCreating,
}: CreateDashboardDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Dashboard</DialogTitle>
<DialogDescription>Enter a name for your dashboard to create it.</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="space-y-2">
<label htmlFor="dashboard-name" className="text-sm font-medium text-gray-900">
Dashboard Name
</label>
<Input
id="dashboard-name"
placeholder="Dashboard name"
value={dashboardName}
onChange={(e) => onDashboardNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && dashboardName.trim() && !isCreating) {
onCreate();
}
}}
autoFocus
/>
</div>
<div className="space-y-2">
<label htmlFor="dashboard-description" className="text-sm font-medium text-gray-900">
Description (Optional)
</label>
<Input
id="dashboard-description"
placeholder="Dashboard description"
value={dashboardDescription}
onChange={(e) => onDashboardDescriptionChange(e.target.value)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button onClick={onCreate} loading={isCreating} disabled={!dashboardName.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,120 +0,0 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { TDashboard } from "../../types/analysis";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
interface DashboardDropdownMenuProps {
environmentId: string;
dashboard: TDashboard;
disabled?: boolean;
deleteDashboard: (dashboardId: string) => void;
}
export const DashboardDropdownMenu = ({
environmentId,
dashboard,
disabled,
deleteDashboard,
}: DashboardDropdownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDeleteDashboard = async (dashboardId: string) => {
setLoading(true);
try {
// TODO: Implement deleteDashboardAction
// await deleteDashboardAction({ dashboardId });
deleteDashboard(dashboardId);
toast.success("Dashboard deleted successfully");
} catch (error) {
toast.error("Error deleting dashboard");
} finally {
setLoading(false);
}
};
return (
<div
id={`${dashboard.name.toLowerCase().split(" ").join("-")}-dashboard-actions`}
data-testid="dashboard-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}
onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open options</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/analysis/dashboard/${dashboard.id}`}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
// TODO: Implement duplicate functionality
toast.success("Duplicate functionality coming soon");
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Dashboard"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteDashboard(dashboard.id)}
text="Are you sure you want to delete this dashboard? This action cannot be undone."
isDeleting={loading}
/>
</div>
);
};

View File

@@ -1,97 +0,0 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { BarChart3Icon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { TDashboard } from "../../types/analysis";
import { DashboardDropdownMenu } from "./DashboardDropdownMenu";
interface DashboardsListClientProps {
dashboards: TDashboard[];
environmentId: string;
}
export function DashboardsListClient({
dashboards: initialDashboards,
environmentId,
}: DashboardsListClientProps) {
const [searchQuery, setSearchQuery] = useState("");
const [dashboards, setDashboards] = useState(initialDashboards);
const filteredDashboards = dashboards.filter((dashboard) =>
dashboard.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const deleteDashboard = (dashboardId: string) => {
setDashboards(dashboards.filter((d) => d.id !== dashboardId));
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">Title</div>
<div className="col-span-1 hidden text-center sm:block">Charts</div>
<div className="col-span-1 hidden text-center sm:block">Created By</div>
<div className="col-span-1 hidden text-center sm:block">Created</div>
<div className="col-span-1 hidden text-center sm:block">Updated</div>
<div className="col-span-1"></div>
</div>
{filteredDashboards.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">No dashboards found.</p>
) : (
<>
{filteredDashboards.map((dashboard) => (
<Link
key={dashboard.id}
href={`dashboard/${dashboard.id}`}
className="grid h-12 w-full cursor-pointer grid-cols-8 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
<BarChart3Icon className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{dashboard.name}</div>
{dashboard.description && (
<div className="ph-no-capture text-xs font-medium text-slate-500">
{dashboard.description}
</div>
)}
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{dashboard.chartCount}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{dashboard.createdByName || "-"}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{format(new Date(dashboard.createdAt), "do 'of' MMMM, yyyy")}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(new Date(dashboard.updatedAt), {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div
className="col-span-1 my-auto flex items-center justify-end pr-6"
onClick={(e) => e.stopPropagation()}>
<DashboardDropdownMenu
environmentId={environmentId}
dashboard={dashboard}
deleteDashboard={deleteDashboard}
/>
</div>
</Link>
))}
</>
)}
</div>
);
}

View File

@@ -1,13 +0,0 @@
import { getDashboards } from "../lib/data";
import { DashboardsListClient } from "./components/DashboardsListClient";
export default async function DashboardsListPage({
params,
}: {
params: Promise<{ environmentId: string }>;
}) {
const { environmentId } = await params;
const dashboards = await getDashboards(environmentId);
return <DashboardsListClient dashboards={dashboards} environmentId={environmentId} />;
}

View File

@@ -1,11 +0,0 @@
import { AnalysisLayoutClient } from "./components/analysis-layout-client";
export default function AnalysisLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ environmentId: string }>;
}) {
return <AnalysisLayoutClient params={params}>{children}</AnalysisLayoutClient>;
}

View File

@@ -1,178 +0,0 @@
"use server";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getUser } from "@/lib/user/service";
import { TDashboard, TChart } from "../types/analysis";
/**
* Fetches all dashboards for the given environment
*/
export const getDashboards = reactCache(async (environmentId: string): Promise<TDashboard[]> => {
const { project } = await getEnvironmentAuth(environmentId);
const dashboards = await prisma.dashboard.findMany({
where: { projectId: project.id },
orderBy: { createdAt: "desc" },
include: {
widgets: {
select: {
id: true,
type: true,
title: true,
chartId: true,
layout: true,
},
},
},
});
// Fetch user names for createdBy fields
const userIds = [...new Set(dashboards.map((d) => d.createdBy).filter(Boolean) as string[])];
const users = await Promise.all(userIds.map((id) => getUser(id)));
const userMap = new Map(users.filter(Boolean).map((u) => [u!.id, u!.name]));
// Transform to match TDashboard type
return dashboards.map((dashboard) => {
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
const createdByName = dashboard.createdBy ? userMap.get(dashboard.createdBy) : undefined;
return {
id: dashboard.id,
name: dashboard.name,
description: dashboard.description || undefined,
status: dashboard.status,
owners: [], // TODO: Fetch owners if needed
lastModified: dashboard.updatedAt.toISOString(),
createdAt: dashboard.createdAt.toISOString(),
updatedAt: dashboard.updatedAt.toISOString(),
createdBy: dashboard.createdBy || undefined,
createdByName,
chartCount,
isFavorite: false, // TODO: Add favorite functionality if needed
widgets: dashboard.widgets.map((widget) => ({
id: widget.id,
type: widget.type as "chart" | "markdown" | "header" | "divider",
title: widget.title || undefined,
chartId: widget.chartId || undefined,
layout: widget.layout as { x: number; y: number; w: number; h: number },
})),
};
});
});
/**
* Fetches all charts for the given environment
*/
export const getCharts = reactCache(async (environmentId: string): Promise<TChart[]> => {
const { project } = await getEnvironmentAuth(environmentId);
const charts = await prisma.chart.findMany({
where: { projectId: project.id },
orderBy: { createdAt: "desc" },
include: {
widgets: {
select: {
dashboardId: true,
},
},
},
});
// Fetch user names for createdBy fields
const userIds = [...new Set(charts.map((c) => c.createdBy).filter(Boolean) as string[])];
const users = await Promise.all(userIds.map((id) => getUser(id)));
const userMap = new Map(users.filter(Boolean).map((u) => [u!.id, u!.name]));
// Transform to match TChart type
return charts.map((chart) => {
const createdByName = chart.createdBy ? userMap.get(chart.createdBy) : undefined;
return {
id: chart.id,
name: chart.name,
type: chart.type as TChart["type"],
dataset: "FeedbackRecords", // TODO: Make this dynamic if needed
owners: [], // TODO: Fetch owners if needed
lastModified: chart.updatedAt.toISOString(),
createdAt: chart.createdAt.toISOString(),
updatedAt: chart.updatedAt.toISOString(),
createdBy: chart.createdBy || undefined,
createdByName,
dashboardIds: chart.widgets.map((widget) => widget.dashboardId),
config: (chart.config as Record<string, any>) || {},
};
});
});
/**
* Fetches a single dashboard by ID
*/
export const getDashboard = reactCache(
async (environmentId: string, dashboardId: string): Promise<TDashboard | null> => {
const { project } = await getEnvironmentAuth(environmentId);
const dashboard = await prisma.dashboard.findFirst({
where: {
id: dashboardId,
projectId: project.id,
},
include: {
widgets: {
include: {
chart: {
select: {
id: true,
name: true,
type: true,
query: true,
config: true,
},
},
},
orderBy: {
order: "asc",
},
},
},
});
if (!dashboard) {
return null;
}
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
return {
id: dashboard.id,
name: dashboard.name,
description: dashboard.description || undefined,
status: dashboard.status,
owners: [], // TODO: Fetch owners if needed
lastModified: dashboard.updatedAt.toISOString(),
createdAt: dashboard.createdAt.toISOString(),
updatedAt: dashboard.updatedAt.toISOString(),
createdBy: dashboard.createdBy || undefined,
createdByName: undefined, // Will be fetched if needed
chartCount,
isFavorite: false, // TODO: Add favorite functionality if needed
widgets: dashboard.widgets.map((widget) => ({
id: widget.id,
type: widget.type as "chart" | "markdown" | "header" | "divider",
title: widget.title || undefined,
chartId: widget.chartId || undefined,
layout: widget.layout as { x: number; y: number; w: number; h: number },
chart: widget.chart
? {
id: widget.chart.id,
name: widget.chart.name,
type: widget.chart.type as TChart["type"],
query: widget.chart.query as Record<string, any>,
config: (widget.chart.config as Record<string, any>) || {},
}
: undefined,
})),
};
}
);

View File

@@ -1,9 +0,0 @@
import { redirect } from "next/navigation";
export default async function AnalysisPage({ params }: { params: Promise<{ environmentId: string }> }) {
const { environmentId } = await params;
if (!environmentId || environmentId === "undefined") {
redirect("/");
}
redirect(`/environments/${environmentId}/analysis/dashboards`);
}

View File

@@ -1,84 +0,0 @@
export type TDashboardStatus = "published" | "draft";
export interface TAnalysisUser {
id: string;
name: string;
}
export interface TDashboard {
id: string;
name: string;
description?: string;
status: TDashboardStatus;
owners: TAnalysisUser[];
lastModified: string; // ISO Date string
createdAt: string; // ISO Date string
updatedAt: string; // ISO Date string
createdBy?: string; // User ID
createdByName?: string; // User name for display
chartCount: number;
isFavorite: boolean;
widgets: TDashboardWidget[];
}
export interface TDashboardWidget {
id: string;
type: "chart" | "markdown" | "header" | "divider";
title?: string;
chartId?: string; // If type is chart
layout: {
x: number;
y: number;
w: number;
h: number;
};
chart?: {
id: string;
name: string;
type: TChartType;
query: Record<string, any>;
config: Record<string, any>;
};
}
export type TChartType =
| "area"
| "bar"
| "line"
| "pie"
| "big_number"
| "big_number_total"
| "table"
| "funnel"
| "map";
export interface TChart {
id: string;
name: string;
type: TChartType;
dataset: string;
owners: TAnalysisUser[];
lastModified: string;
createdAt: string; // ISO Date string
updatedAt: string; // ISO Date string
createdBy?: string; // User ID
createdByName?: string; // User name for display
dashboardIds: string[];
config: Record<string, any>; // Flexible config for specific chart props
}
export interface TAnalysisState {
dashboards: TDashboard[];
charts: TChart[];
activeDashboard: TDashboard | null;
layoutMode: "view" | "edit";
isLoading: boolean;
// Actions
setDashboards: (dashboards: TDashboard[]) => void;
setCharts: (charts: TChart[]) => void;
setActiveDashboard: (dashboard: TDashboard | null) => void;
setLayoutMode: (mode: "view" | "edit") => void;
addDashboard: (dashboard: TDashboard) => void;
updateDashboard: (id: string, updates: Partial<TDashboard>) => void;
}

View File

@@ -1 +0,0 @@
export * from "./analysis";

View File

@@ -8,7 +8,6 @@ import {
MessageCircle,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PieChart,
RocketIcon,
UserCircleIcon,
UserIcon,
@@ -106,13 +105,6 @@ export const MainNavigation = ({
isActive: pathname?.includes("/surveys"),
isHidden: false,
},
{
name: t("common.analysis"),
href: `/environments/${environment.id}/analysis`,
icon: PieChart,
isActive: pathname?.includes("/analysis"),
isHidden: false,
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
@@ -193,7 +185,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -11,12 +11,6 @@ const EnvLayout = async (props: {
children: React.ReactNode;
}) => {
const params = await props.params;
const { environmentId } = params;
if (environmentId === "undefined") {
return redirect("/");
}
const { children } = props;
// Check session first (required for userId)

View File

@@ -1,51 +0,0 @@
import cubejs from "@cubejs-client/core";
/**
* Cube.js client for executing queries
* No authentication - for POC purposes only
*/
// Cube API configuration - defaults to localhost:4000, can be overridden via env var
// Automatically append /cubejs-api/v1 if not present
const getApiUrl = () => {
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
// If the URL already contains /cubejs-api/v1, use it as-is
if (baseUrl.includes("/cubejs-api/v1")) {
return baseUrl;
}
// Otherwise, append the path
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
};
const API_URL = getApiUrl();
/**
* Create a Cube.js client instance without authentication
* For POC - Cube.js must be configured to allow unauthenticated requests
*/
export function createCubeClient() {
// Empty string = no authentication token
return cubejs("", {
apiUrl: API_URL,
});
}
/**
* Execute a Cube.js query and return the table pivot data
* @param query - The Cube.js query object
* @returns Array of row objects with measure/dimension values
*/
export async function executeQuery(query: any) {
try {
const client = createCubeClient();
console.log("Executing Cube.js query:", JSON.stringify(query, null, 2));
console.log("Cube.js API URL:", API_URL);
const resultSet = await client.load(query);
return resultSet.tablePivot();
} catch (error: any) {
console.error("Cube.js query error:", error);
console.error("Query that failed:", JSON.stringify(query, null, 2));
console.error("API URL used:", API_URL);
throw error;
}
}

View File

@@ -1,286 +0,0 @@
import fs from "fs";
import path from "path";
/**
* Parses the Cube.js schema file to extract measures and dimensions
* This keeps the schema as the single source of truth for AI query generation
*/
interface MeasureInfo {
name: string;
description: string;
}
interface DimensionInfo {
name: string;
description: string;
type: "string" | "number" | "time";
}
// Path to schema file - self-contained within the analytics folder
const SCHEMA_FILE_PATH = path.join(process.cwd(), "app", "api", "analytics", "_schema", "FeedbackRecords.js");
/**
* Extract description from a schema property object
*/
function extractDescription(objStr: string): string {
const descMatch = objStr.match(/description:\s*`([^`]+)`/);
return descMatch ? descMatch[1] : "";
}
/**
* Extract type from a dimension object
*/
function extractType(objStr: string): "string" | "number" | "time" {
const typeMatch = objStr.match(/type:\s*`(string|number|time)`/);
return (typeMatch?.[1] as "string" | "number" | "time") || "string";
}
/**
* Helper to extract content inside the first matching brace block
*/
function extractInnerBlockContent(content: string, startRegex: RegExp): string | null {
const match = content.match(startRegex);
if (!match) return null;
// Backtrack to find the opening brace in the match
const braceIndex = match[0].lastIndexOf("{");
if (braceIndex === -1) return null; // Should not happen given regex usage
// Actually we can just start scanning from the end of the match if the regex ends with {
// But let's be safer: start counting from the opening brace.
const absoluteStartIndex = match.index! + braceIndex;
let braceCount = 1;
let i = absoluteStartIndex + 1;
while (braceCount > 0 && i < content.length) {
if (content[i] === "{") braceCount++;
else if (content[i] === "}") braceCount--;
i++;
}
if (braceCount === 0) {
return content.substring(absoluteStartIndex + 1, i - 1);
}
return null;
}
/**
* Parse measures from the schema file
*/
function parseMeasures(schemaContent: string): MeasureInfo[] {
const measures: MeasureInfo[] = [];
const measuresBlock = extractInnerBlockContent(schemaContent, /measures:\s*\{/);
if (!measuresBlock) return measures;
// Match each measure: measureName: { ... }
const measureRegex = /(\w+):\s*\{/g;
let match;
while ((match = measureRegex.exec(measuresBlock)) !== null) {
const name = match[1];
const startIndex = match.index + match[0].length;
// Find the matching closing brace
let braceCount = 1;
let endIndex = startIndex;
while (braceCount > 0 && endIndex < measuresBlock.length) {
if (measuresBlock[endIndex] === "{") braceCount++;
if (measuresBlock[endIndex] === "}") braceCount--;
endIndex++;
}
const body = measuresBlock.substring(startIndex, endIndex - 1);
const description = extractDescription(body);
if (description) {
measures.push({ name, description });
}
}
return measures;
}
/**
* Parse dimensions from a specific cube
*/
function parseDimensionsFromCube(cubeContent: string, cubeName: string): DimensionInfo[] {
const dimensions: DimensionInfo[] = [];
const dimensionsBlock = extractInnerBlockContent(cubeContent, /dimensions:\s*\{/);
if (!dimensionsBlock) return dimensions;
// Match each dimension: dimensionName: { ... }
const dimensionRegex = /(\w+):\s*\{/g;
let match;
while ((match = dimensionRegex.exec(dimensionsBlock)) !== null) {
const name = match[1];
const startIndex = match.index + match[0].length;
// Find the matching closing brace
let braceCount = 1;
let endIndex = startIndex;
while (braceCount > 0 && endIndex < dimensionsBlock.length) {
if (dimensionsBlock[endIndex] === "{") braceCount++;
if (dimensionsBlock[endIndex] === "}") braceCount--;
endIndex++;
}
const body = dimensionsBlock.substring(startIndex, endIndex - 1);
const description = extractDescription(body);
const type = extractType(body);
// Skip primaryKey dimensions (like 'id') and internal dimensions
if (body.includes("primaryKey: true") || name === "feedbackRecordId") {
continue;
}
if (description) {
dimensions.push({
name: cubeName === "FeedbackRecords" ? name : `${cubeName}.${name}`,
description,
type,
});
}
}
return dimensions;
}
/**
* Parse dimensions from the schema file
*/
function parseDimensions(schemaContent: string): DimensionInfo[] {
const dimensions: DimensionInfo[] = [];
// Extract dimensions from FeedbackRecords cube
const feedbackRecordsMatch = schemaContent.match(/cube\(`FeedbackRecords`,\s*\{([\s\S]*?)\n\}\);/);
if (feedbackRecordsMatch) {
const feedbackRecordsDimensions = parseDimensionsFromCube(feedbackRecordsMatch[1], "FeedbackRecords");
dimensions.push(...feedbackRecordsDimensions);
}
// Extract dimensions from TopicsUnnested cube
const topicsUnnestedMatch = schemaContent.match(/cube\(`TopicsUnnested`,\s*\{([\s\S]*?)\n\}\);/);
if (topicsUnnestedMatch) {
const topicsDimensions = parseDimensionsFromCube(topicsUnnestedMatch[1], "TopicsUnnested");
dimensions.push(...topicsDimensions);
}
return dimensions;
}
/**
* Read and parse the schema file
*/
export function parseSchemaFile(): {
measures: MeasureInfo[];
dimensions: DimensionInfo[];
} {
try {
const schemaContent = fs.readFileSync(SCHEMA_FILE_PATH, "utf-8");
const measures = parseMeasures(schemaContent);
const dimensions = parseDimensions(schemaContent);
return { measures, dimensions };
} catch (error) {
console.error("Error parsing schema file:", error);
// Fallback to empty arrays if parsing fails
return { measures: [], dimensions: [] };
}
}
/**
* Generate the schema context string for AI query generation
*/
export function generateSchemaContext(): string {
const { measures, dimensions } = parseSchemaFile();
const CUBE_NAME = "FeedbackRecords";
const measuresList = measures.map((m) => `- ${CUBE_NAME}.${m.name} - ${m.description}`).join("\n");
const dimensionsList = dimensions
.map((d) => {
const typeLabel = d.type === "time" ? " (time dimension)" : ` (${d.type})`;
// Dimensions from TopicsUnnested already have the cube prefix
const fullName = d.name.includes(".") ? d.name : `${CUBE_NAME}.${d.name}`;
return `- ${fullName} - ${d.description}${typeLabel}`;
})
.join("\n");
const categoricalDimensions = dimensions
.filter(
(d) =>
d.type === "string" &&
!d.name.includes("responseId") &&
!d.name.includes("userIdentifier") &&
!d.name.includes("feedbackRecordId")
)
.map((d) => (d.name.includes(".") ? d.name : `${CUBE_NAME}.${d.name}`))
.join(", ");
return `
You are a CubeJS query generator. Your task is to convert natural language requests into valid CubeJS query JSON objects.
Available Cubes: ${CUBE_NAME}, TopicsUnnested
MEASURES (use these in the "measures" array):
${measuresList}
DIMENSIONS (use these in the "dimensions" array):
${dimensionsList}
TIME DIMENSIONS:
- ${CUBE_NAME}.collectedAt can be used with granularity: 'day', 'week', 'month', 'year'
- Use "timeDimensions" array for time-based queries with dateRange like "last 7 days", "last 30 days", "this month", etc.
CHART TYPE SUGGESTIONS:
- If query has timeDimensions → suggest "bar" or "line"
- If query has categorical dimensions (${categoricalDimensions}) → suggest "donut" or "bar"
- If query has only measures → suggest "kpi"
- If query compares multiple measures → suggest "bar"
FILTERS:
- Use "filters" array to include/exclude records based on dimension values
- Filter format: { "member": "CubeName.dimensionName", "operator": "operator" } OR { "member": "CubeName.dimensionName", "operator": "operator", "values": [...] }
- Common operators:
* "set" - dimension is not null/empty (Set "values" to null)
Example: { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
* "notSet" - dimension is null/empty (Set "values" to null)
Example: { "member": "${CUBE_NAME}.emotion", "operator": "notSet", "values": null }
* "equals" - exact match (REQUIRES "values" field)
Example: { "member": "${CUBE_NAME}.emotion", "operator": "equals", "values": ["happy"] }
* "notEquals" - not equal (REQUIRES "values" field)
Example: { "member": "${CUBE_NAME}.emotion", "operator": "notEquals", "values": ["sad"] }
* "contains" - contains text (REQUIRES "values" field)
Example: { "member": "${CUBE_NAME}.emotion", "operator": "contains", "values": ["happy"] }
- Examples for common user requests:
* "only records with emotion" or "for records that have emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
* "exclude records without emotion" or "do not include records without emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
* "exclude records with emotion" or "do not include records with emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "notSet", "values": null }
* "only happy emotions" → { "member": "${CUBE_NAME}.emotion", "operator": "equals", "values": ["happy"] }
IMPORTANT RULES:
1. Always return valid JSON only, no markdown or code blocks
2. Use exact measure/dimension names as listed above
3. Include "chartType" field: "bar", "line", "donut", "kpi", or "area"
4. For time queries, use timeDimensions array with granularity and dateRange
5. Return format: { "measures": [...], "dimensions": [...], "timeDimensions": [...], "filters": [...], "chartType": "..." }
6. If user asks about trends over time, use timeDimensions
7. If user asks "by X", add X as a dimension
8. If user asks for counts or totals, use ${CUBE_NAME}.count
9. If user asks for NPS, use ${CUBE_NAME}.npsScore
10. If user asks about topics, use TopicsUnnested.topic (NOT ${CUBE_NAME}.topic)
11. CRITICAL: If user says "only records with X", "exclude records without X", or "for records that have X", add a filter with operator "set" for that dimension
12. CRITICAL: If user says "exclude records with X", "do not include records with X", or "without X", add a filter with operator "notSet" for that dimension
13. Always include filters when user explicitly mentions including/excluding records based on dimension values
`.trim();
}
export const CUBE_NAME = "FeedbackRecords";

View File

@@ -1,39 +0,0 @@
/**
* TypeScript types for the Analytics API
*/
export interface TimeDimension {
dimension: string;
granularity?: "hour" | "day" | "week" | "month" | "quarter" | "year";
dateRange?: string | string[];
}
export interface Filter {
member: string;
operator:
| "equals"
| "notEquals"
| "contains"
| "notContains"
| "set"
| "notSet"
| "gt"
| "gte"
| "lt"
| "lte";
values?: string[] | null;
}
export interface CubeQuery {
measures: string[];
dimensions?: string[];
timeDimensions?: TimeDimension[];
filters?: Filter[];
}
export interface AnalyticsResponse {
query: CubeQuery;
chartType: "bar" | "line" | "donut" | "kpi" | "area" | "pie";
data?: Record<string, any>[];
error?: string;
}

View File

@@ -1,156 +0,0 @@
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
measures: {
count: {
type: `count`,
description: `Total number of feedback responses`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS score 0-6)`,
},
passiveCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
},
averageScore: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
},
},
dimensions: {
id: {
sql: `id`,
type: `string`,
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
},
sourceName: {
sql: `source_name`,
type: `string`,
description: `Human-readable name of the source`,
},
fieldType: {
sql: `field_type`,
type: `string`,
description: `Type of feedback field (e.g., nps, text, rating)`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
},
responseId: {
sql: `response_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
},
userIdentifier: {
sql: `user_identifier`,
type: `string`,
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `feedback_record_id || '-' || topic`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});

View File

@@ -1,246 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
import { z } from "zod";
import { executeQuery } from "../_lib/cube-client";
import { CUBE_NAME, generateSchemaContext } from "../_lib/schema-parser";
const schema = z.object({
measures: z.array(z.string()).describe("List of measures to query"),
dimensions: z.array(z.string()).nullable().describe("List of dimensions to query"),
timeDimensions: z
.array(
z.object({
dimension: z.string(),
granularity: z.enum(["day", "week", "month", "year"]).nullable(),
dateRange: z.string().nullable(),
})
)
.nullable()
.describe("Time dimensions with granularity and date range"),
chartType: z
.enum(["bar", "line", "donut", "kpi", "area", "pie"])
.describe("Suggested chart type for visualization"),
filters: z
.array(
z.object({
member: z.string(),
operator: z.enum([
"equals",
"notEquals",
"contains",
"notContains",
"set",
"notSet",
"gt",
"gte",
"lt",
"lte",
]),
values: z.array(z.string()).nullable(),
})
)
.nullable()
.describe("Filters to apply to the query"),
});
// Generate schema context dynamically from the schema file
const SCHEMA_CONTEXT = generateSchemaContext();
// JSON Schema for OpenAI structured outputs (manually created to avoid zod-to-json-schema dependency)
const jsonSchema = {
type: "object",
additionalProperties: false,
properties: {
measures: {
type: "array",
items: { type: "string" },
description: "List of measures to query",
},
dimensions: {
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
description: "List of dimensions to query",
},
timeDimensions: {
anyOf: [
{
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
dimension: { type: "string" },
granularity: {
anyOf: [{ type: "string", enum: ["day", "week", "month", "year"] }, { type: "null" }],
},
dateRange: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
required: ["dimension", "granularity", "dateRange"],
},
},
{ type: "null" },
],
description: "Time dimensions with granularity and date range",
},
chartType: {
type: "string",
enum: ["bar", "line", "donut", "kpi", "area", "pie"],
description: "Suggested chart type for visualization",
},
filters: {
anyOf: [
{
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
member: { type: "string" },
operator: {
type: "string",
enum: [
"equals",
"notEquals",
"contains",
"notContains",
"set",
"notSet",
"gt",
"gte",
"lt",
"lte",
],
},
values: {
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
},
},
required: ["member", "operator", "values"],
},
},
{ type: "null" },
],
description: "Filters to apply to the query",
},
},
required: ["measures", "dimensions", "timeDimensions", "chartType", "filters"],
} as const;
// Initialize OpenAI client
const getOpenAIClient = () => {
if (!process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is not configured");
}
return new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
};
export async function POST(request: NextRequest) {
try {
const { prompt, executeQuery: shouldExecuteQuery = true } = await request.json();
if (!prompt || typeof prompt !== "string") {
return NextResponse.json({ error: "Prompt is required and must be a string" }, { status: 400 });
}
const openai = getOpenAIClient();
// Generate Cube.js query using OpenAI structured outputs
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SCHEMA_CONTEXT },
{ role: "user", content: `User request: "${prompt}"` },
],
tools: [
{
type: "function",
function: {
name: "generate_cube_query",
description: "Generate a Cube.js query based on the user request",
parameters: jsonSchema,
strict: true, // Enable structured outputs
},
},
],
tool_choice: { type: "function", function: { name: "generate_cube_query" } },
});
const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
if (toolCall?.function.name !== "generate_cube_query") {
throw new Error("Failed to generate structured output from OpenAI");
}
const query = JSON.parse(toolCall.function.arguments);
// Validate with zod schema (for type safety)
const validatedQuery = schema.parse(query);
// Validate required fields (measures should minimally be present if not specified, default to count)
if (!validatedQuery.measures || validatedQuery.measures.length === 0) {
validatedQuery.measures = [`${CUBE_NAME}.count`];
}
// Extract chartType (for UI purposes only, not part of CubeJS query)
const { chartType, ...cubeQuery } = validatedQuery;
// Clean up null/empty values to conform to CubeJS expectations
if (
cubeQuery.dimensions === null ||
(Array.isArray(cubeQuery.dimensions) && cubeQuery.dimensions.length === 0)
) {
delete (cubeQuery as any).dimensions;
}
if (!cubeQuery.filters || cubeQuery.filters.length === 0) {
delete (cubeQuery as any).filters;
} else {
// Clean up null values in filters
cubeQuery.filters = cubeQuery.filters.map((f: any) => {
const newFilter: any = { ...f };
if (newFilter.values === null) delete newFilter.values;
return newFilter;
});
}
if (cubeQuery.timeDimensions === null) {
delete (cubeQuery as any).timeDimensions;
} else if (Array.isArray(cubeQuery.timeDimensions)) {
// Filter out null properties in timeDimensions objects
cubeQuery.timeDimensions = cubeQuery.timeDimensions.map((td: any) => {
const newTd: any = { ...td };
if (newTd.granularity === null) delete newTd.granularity;
if (newTd.dateRange === null) delete newTd.dateRange;
return newTd;
});
}
// Execute query if requested (default: true)
let data: Record<string, any>[] | undefined;
if (shouldExecuteQuery) {
try {
data = await executeQuery(cubeQuery);
} catch (queryError: any) {
console.error("Error executing Cube.js query:", queryError);
// Return the query even if execution fails, so client can retry
return NextResponse.json(
{
query: cubeQuery,
chartType,
error: `Failed to execute query: ${queryError.message || "Unknown error"}`,
},
{ status: 500 }
);
}
}
return NextResponse.json({
query: cubeQuery,
chartType,
data,
});
} catch (error: any) {
console.error("Error generating query:", error);
return NextResponse.json({ error: error.message || "Failed to generate query" }, { status: 500 });
}
}

View File

@@ -8,7 +8,9 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
@@ -113,6 +115,27 @@ export const PUT = withV1ApiWrapper({
};
}
// Validate response data against validation rules (only if data is provided)
const updateData = inputValidation.data.data;
if (updateData) {
const validationErrors = validateResponseData(
survey.blocks,
updateData,
inputValidation.data.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Response validation failed",
formatValidationErrors(validationErrors),
true
),
};
}
}
// update response with quota evaluation
let updatedResponse;
try {

View File

@@ -12,6 +12,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -123,6 +125,24 @@ export const POST = withV1ApiWrapper({
};
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Response validation failed",
formatValidationErrors(validationErrors),
true
),
};
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {

View File

@@ -8,10 +8,8 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
@@ -156,7 +154,7 @@ export const PUT = withV1ApiWrapper({
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
formatValidationErrors(validationErrors),
true
),
};

View File

@@ -7,10 +7,8 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
@@ -165,7 +163,7 @@ export const POST = withV1ApiWrapper({
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
formatValidationErrors(validationErrors),
true
),
};

View File

@@ -11,7 +11,9 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { createResponseWithQuotaEvaluation } from "./lib/response";
@@ -106,6 +108,22 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Response validation failed",
formatValidationErrors(validationErrors),
true
);
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {

View File

@@ -106,7 +106,6 @@ checksums:
common/allow: 3e39cc5940255e6bff0fea95c817dd43
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
common/and: dc75b95c804b16dc617a5f16f7393bca
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
common/anonymous: 77b5222e710cc1dae073dae32309f8ed

View File

@@ -133,7 +133,6 @@
"allow": "erlauben",
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
"analysis": "Analyse",
"and": "und",
"and_response_limit_of": "und Antwortlimit von",
"anonymous": "Anonym",

View File

@@ -133,7 +133,6 @@
"allow": "Allow",
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
"analysis": "Analysis",
"and": "And",
"and_response_limit_of": "and response limit of",
"anonymous": "Anonymous",

View File

@@ -133,7 +133,6 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
"analysis": "Análisis",
"and": "Y",
"and_response_limit_of": "y límite de respuesta de",
"anonymous": "Anónimo",

View File

@@ -133,7 +133,6 @@
"allow": "Autoriser",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
"analysis": "Analyse",
"and": "Et",
"and_response_limit_of": "et limite de réponse de",
"anonymous": "Anonyme",

View File

@@ -133,7 +133,6 @@
"allow": "許可",
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
"analysis": "分析",
"and": "および",
"and_response_limit_of": "と回答数の上限",
"anonymous": "匿名",

View File

@@ -133,7 +133,6 @@
"allow": "Toestaan",
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
"analysis": "Analyse",
"and": "En",
"and_response_limit_of": "en responslimiet van",
"anonymous": "Anoniem",

View File

@@ -133,7 +133,6 @@
"allow": "permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anônimo",

View File

@@ -133,7 +133,6 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo",

View File

@@ -133,7 +133,6 @@
"allow": "Permite",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
"analysis": "Analiză",
"and": "Și",
"and_response_limit_of": "și limită răspuns",
"anonymous": "Anonim",

View File

@@ -133,7 +133,6 @@
"allow": "Разрешить",
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
"analysis": "Анализ",
"and": "и",
"and_response_limit_of": "и лимит ответов",
"anonymous": "Аноним",

View File

@@ -133,7 +133,6 @@
"allow": "Tillåt",
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
"analysis": "Analys",
"and": "Och",
"and_response_limit_of": "och svarsgräns på",
"anonymous": "Anonym",

View File

@@ -133,7 +133,6 @@
"allow": "允许",
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
"analysis": "分析",
"and": "和",
"and_response_limit_of": "和 响应限制",
"anonymous": "匿名",

View File

@@ -133,7 +133,6 @@
"allow": "允許",
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
"analysis": "分析",
"and": "且",
"and_response_limit_of": "且回應上限為",
"anonymous": "匿名",

View File

@@ -3,7 +3,6 @@
import { CheckCircle2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -68,16 +67,6 @@ export const SingleResponseCardBody = ({
<VerifiedEmail responseData={response.data} />
)}
{elements.map((question) => {
// Skip CTA elements without external buttons only if they have no response data
// This preserves historical data from when buttonExternal was true
if (
question.type === TSurveyElementTypeEnum.CTA &&
!question.buttonExternal &&
!response.data[question.id]
) {
return null;
}
const skipped = skippedQuestions.find((skippedQuestionElement) =>
skippedQuestionElement.includes(question.id)
);

View File

@@ -0,0 +1,21 @@
import "server-only";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
/**
* Converts validation error map to API error response format as Record<string, string>
* Used by both v1 and v2 client APIs for consistent error formatting
*
* @param errorMap - Validation error map from validateResponseData
* @returns API error details as Record<string, string> where keys are field paths and values are combined error messages
*/
export const formatValidationErrors = (errorMap: TValidationErrorMap): Record<string, string> => {
const details: Record<string, string> = {};
for (const [elementId, errors] of Object.entries(errorMap)) {
// Combine all error messages for each element
const errorMessages = errors.map((error) => error.message).join("; ");
details[`response.data.${elementId}`] = errorMessages;
}
return details;
};

View File

@@ -4,11 +4,8 @@ import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
import {
formatValidationErrorsForApi,
formatValidationErrorsForV1Api,
validateResponseData,
} from "./validation";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { formatValidationErrorsForApi, validateResponseData } from "./validation";
const mockTransformQuestionsToBlocks = vi.fn();
const mockGetElementsFromBlocks = vi.fn();
@@ -172,13 +169,13 @@ describe("formatValidationErrorsForApi", () => {
});
});
describe("formatValidationErrorsForV1Api", () => {
test("should convert error map to V1 API format", () => {
describe("formatValidationErrors", () => {
test("should convert error map to Record format", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
};
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
expect(formatValidationErrors(errorMap)).toEqual({
"response.data.element1": "Min length required",
});
});
@@ -191,7 +188,7 @@ describe("formatValidationErrorsForV1Api", () => {
],
};
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
expect(formatValidationErrors(errorMap)).toEqual({
"response.data.element1": "Min length; Max length",
});
});
@@ -202,7 +199,7 @@ describe("formatValidationErrorsForV1Api", () => {
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
};
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
expect(formatValidationErrors(errorMap)).toEqual({
"response.data.element1": "Min length",
"response.data.element2": "Max length",
});

View File

@@ -72,21 +72,3 @@ export const formatValidationErrorsForApi = (errorMap: TValidationErrorMap) => {
return details;
};
/**
* Converts validation error map to V1 API error response format
*
* @param errorMap - Validation error map from validateResponseData
* @returns V1 API error details as Record<string, string>
*/
export const formatValidationErrorsForV1Api = (errorMap: TValidationErrorMap): Record<string, string> => {
const details: Record<string, string> = {};
for (const [elementId, errors] of Object.entries(errorMap)) {
// Combine all error messages for each element
const errorMessages = errors.map((error) => error.message).join("; ");
details[`response.data.${elementId}`] = errorMessages;
}
return details;
};

View File

@@ -425,19 +425,11 @@ export const SurveyMenuBar = ({
const segment = await handleSegmentUpdate();
clearSurveyLocalStorage();
const publishResult = await updateSurveyAction({
await updateSurveyAction({
...localSurvey,
status,
segment,
});
if (!publishResult?.data) {
const errorMessage = getFormattedErrorMessage(publishResult);
toast.error(errorMessage);
setIsSurveyPublishing(false);
return;
}
setIsSurveyPublishing(false);
// Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true;
@@ -475,7 +467,7 @@ export const SurveyMenuBar = ({
/>
</div>
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
{!isStorageConfigured && (
<div>

View File

@@ -1,303 +0,0 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { ResponsiveContainer, Tooltip } from "recharts";
import { cn } from "@/lib/cn";
// Format: { THEME_NAME: CSS_VARIABLE }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}>
<ChartStyle id={chartId} config={config} />
<ResponsiveContainer>{children}</ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "ChartContainer";
const ChartTooltip = Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

@@ -38,7 +38,6 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
const [inputValue, setInputValue] = React.useState("");
const [position, setPosition] = React.useState<{ top: number; left: number; width: number } | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const isSelectingRef = React.useRef(false);
// Track if changes are user-initiated (not from value prop)
const isUserInitiatedRef = React.useRef(false);
@@ -145,8 +144,9 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
className={`relative overflow-visible bg-white ${disabled ? "cursor-not-allowed opacity-50" : ""}`}>
<div
ref={containerRef}
className={`border-input ring-offset-background group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2 ${disabled ? "pointer-events-none" : "focus-within:ring-ring"
}`}>
className={`border-input ring-offset-background group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2 ${
disabled ? "pointer-events-none" : "focus-within:ring-ring"
}`}>
<div className="flex flex-wrap gap-1">
{selected.map((option) => (
<Badge key={option.value} className="rounded-md">
@@ -171,12 +171,7 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={(e) => {
// Don't close if we're selecting an option
if (!isSelectingRef.current) {
setOpen(false);
}
}}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
@@ -191,7 +186,7 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
globalThis.window !== undefined &&
createPortal(
<div
className="absolute z-[9999]"
className="absolute z-[100]"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
@@ -206,18 +201,12 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
isSelectingRef.current = true;
}}
onSelect={() => {
if (disabled) return;
isUserInitiatedRef.current = true; // Mark as user-initiated
setSelected((prev) => [...prev, option]);
setInputValue("");
// Reset the flag after a short delay to allow the selection to complete
setTimeout(() => {
isSelectingRef.current = false;
setOpen(false);
}, 100);
}}
className="cursor-pointer">
{option.label}

View File

@@ -259,7 +259,6 @@ export const PreviewSurvey = ({
setBlockId = f;
}}
onFinished={onFinished}
placement={placement}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</Modal>
@@ -364,7 +363,6 @@ export const PreviewSurvey = ({
}}
onFinished={onFinished}
isSpamProtectionEnabled={isSpamProtectionEnabled}
placement={placement}
/>
</Modal>
) : (

View File

@@ -23,7 +23,6 @@
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@boxyhq/saml-jackson": "1.52.2",
"@cubejs-client/core": "1.6.6",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
@@ -80,7 +79,6 @@
"@ungap/structured-clone": "1.3.0",
"@vercel/functions": "2.2.8",
"@vercel/og": "0.8.5",
"openai": "^4.0.0",
"bcryptjs": "3.0.2",
"boring-avatars": "2.0.1",
"class-variance-authority": "0.7.1",
@@ -113,18 +111,15 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react": "19.2.3",
"react-calendar": "5.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
"react-dom": "19.2.3",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"react-i18next": "15.7.3",
"react-turnstile": "1.1.4",
"react-use": "17.6.0",
"recharts": "3.7.0",
"redis": "4.7.0",
"sanitize-html": "2.17.0",
"server-only": "0.0.1",
@@ -139,7 +134,8 @@
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "3.24.4",
"zod-openapi": "4.2.4",
"zustand": "5.0.10"
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -47,13 +47,8 @@ run_with_timeout() {
}
# Check if migrations should be skipped (e.g., when using Helm migration job)
if [ "${SKIP_STARTUP_MIGRATION:-false}" = "true" ]; then
echo "⏭️ Skipping startup migrations (handled by migration job)"
else
echo "🗃️ Running database migrations..."
run_with_timeout 300 "database migration" node packages/database/dist/scripts/apply-migrations.js
fi
echo "🗃️ Running database migrations..."
run_with_timeout 300 "database migration" node packages/database/dist/scripts/apply-migrations.js
echo "🗃️ Running SAML database setup..."
run_with_timeout 60 "SAML database setup" node packages/database/dist/scripts/create-saml-database.js

View File

@@ -1,15 +0,0 @@
module.exports = {
// Validate that the auth payload is present
checkSqlAuth: (req, auth) => {
// In dev mode with API secret, auth should be populated
if (!auth) {
// throw new Error('Authentication required');
}
},
// Rewrite queries based on security context (RLS)
queryRewrite: (query, { securityContext }) => {
console.log('Query Security Context:', securityContext);
return query;
},
};

View File

@@ -1,156 +0,0 @@
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
measures: {
count: {
type: `count`,
description: `Total number of feedback responses`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS score 0-6)`,
},
passiveCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
},
averageScore: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
},
},
dimensions: {
id: {
sql: `id`,
type: `string`,
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
},
sourceName: {
sql: `source_name`,
type: `string`,
description: `Human-readable name of the source`,
},
fieldType: {
sql: `field_type`,
type: `string`,
description: `Type of feedback field (e.g., nps, text, rating)`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
},
responseId: {
sql: `response_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
},
userIdentifier: {
sql: `user_identifier`,
type: `string`,
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `feedback_record_id || '-' || topic`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});

View File

@@ -36,32 +36,6 @@ services:
volumes:
- minio-data:/data
cube:
image: cubejs/cube:latest
ports:
- 4000:4000
- 4001:4001
environment:
# Database connection
# Connect to hub API database (formbricks_hub_postgres container)
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: ${POSTGRES_HOST:-host.docker.internal}
CUBEJS_DB_NAME: hub
CUBEJS_DB_USER: formbricks
CUBEJS_DB_PASS: ${POSTGRES_PASSWORD:-formbricks_dev}
CUBEJS_DB_PORT: ${POSTGRES_PORT:-5433}
# Cube configuration
CUBEJS_DEV_MODE: "true"
CUBEJS_API_SECRET: "25ad91c420ab742a2c0d3b17f93fd871e883304abba7a38e2620eb1416106af2"
# Use memory for caching in dev mode (cubestore or memory are supported)
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- ./cube/cube.js:/cube/conf/cube.js
- ./cube/schema:/cube/conf/model
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
postgres:
driver: local

9
helm-chart/Chart.lock Normal file
View File

@@ -0,0 +1,9 @@
dependencies:
- name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts
version: 16.4.16
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 20.11.2
digest: sha256:6233567e6d133fd87585de7cb11f835125ab649fc7979eac7b17d4b2881f54dc
generated: "2025-03-06T15:48:20.190945+05:30"

33
helm-chart/Chart.yaml Normal file
View File

@@ -0,0 +1,33 @@
apiVersion: v2
name: formbricks
description: A Helm chart for Formbricks with PostgreSQL, Redis
type: application
# Helm chart Version
version: 0.0.0-dev
# This is the version number of the application being deployed.
appVersion: "3.7.0"
icon: https://formbricks.com/favicon.ico
keywords:
- formbricks
- postgresql
- redis
home: https://formbricks.com/docs/self-hosting/setup/kubernetes
maintainers:
- name: Formbricks
email: info@formbricks.com
dependencies:
- name: postgresql
version: "16.4.16"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: postgresql.enabled
- name: redis
version: 20.11.2
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: redis.enabled

157
helm-chart/README.md Normal file
View File

@@ -0,0 +1,157 @@
# formbricks
![Version: 0.0.0-dev](https://img.shields.io/badge/Version-0.0.0--dev-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
A Helm chart for Formbricks with PostgreSQL, Redis
**Homepage:** <https://formbricks.com/docs/self-hosting/setup/kubernetes>
## Maintainers
| Name | Email | Url |
| ---------- | --------------------- | --- |
| Formbricks | <info@formbricks.com> | |
## Requirements
| Repository | Name | Version |
| ---------------------------------------- | ---------- | ------- |
| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.4.16 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
## Values
| Key | Type | Default | Description |
| ------------------------------------------------------------------ | ------ | --------------------------------- | ----------- |
| autoscaling.additionalLabels | object | `{}` | |
| autoscaling.annotations | object | `{}` | |
| autoscaling.enabled | bool | `true` | |
| autoscaling.maxReplicas | int | `10` | |
| autoscaling.metrics[0].resource.name | string | `"cpu"` | |
| autoscaling.metrics[0].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[0].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[0].type | string | `"Resource"` | |
| autoscaling.metrics[1].resource.name | string | `"memory"` | |
| autoscaling.metrics[1].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[1].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[1].type | string | `"Resource"` | |
| autoscaling.minReplicas | int | `1` | |
| componentOverride | string | `""` | |
| cronJob.enabled | bool | `false` | |
| cronJob.jobs | object | `{}` | |
| deployment.additionalLabels | object | `{}` | |
| deployment.additionalPodAnnotations | object | `{}` | |
| deployment.additionalPodLabels | object | `{}` | |
| deployment.affinity | object | `{}` | |
| deployment.annotations | object | `{}` | |
| deployment.args | list | `[]` | |
| deployment.command | list | `[]` | |
| deployment.containerSecurityContext.readOnlyRootFilesystem | bool | `true` | |
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
| deployment.env.EMAIL_VERIFICATION_DISABLED.value | string | `"1"` | |
| deployment.env.PASSWORD_RESET_DISABLED.value | string | `"1"` | |
| deployment.envFrom | string | `nil` | |
| deployment.image.digest | string | `""` | |
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
| deployment.imagePullSecrets | string | `""` | |
| deployment.nodeSelector | object | `{}` | |
| deployment.ports.http.containerPort | int | `3000` | |
| deployment.ports.http.exposed | bool | `true` | |
| deployment.ports.http.protocol | string | `"TCP"` | |
| deployment.ports.metrics.containerPort | int | `9464` | |
| deployment.ports.metrics.exposed | bool | `true` | |
| deployment.ports.metrics.protocol | string | `"TCP"` | |
| deployment.probes.livenessProbe.failureThreshold | int | `5` | |
| deployment.probes.livenessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.livenessProbe.httpGet.port | int | `3000` | |
| deployment.probes.livenessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.livenessProbe.periodSeconds | int | `10` | |
| deployment.probes.livenessProbe.successThreshold | int | `1` | |
| deployment.probes.livenessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.readinessProbe.failureThreshold | int | `5` | |
| deployment.probes.readinessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.readinessProbe.httpGet.port | int | `3000` | |
| deployment.probes.readinessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.readinessProbe.periodSeconds | int | `10` | |
| deployment.probes.readinessProbe.successThreshold | int | `1` | |
| deployment.probes.readinessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.startupProbe.failureThreshold | int | `30` | |
| deployment.probes.startupProbe.periodSeconds | int | `10` | |
| deployment.probes.startupProbe.tcpSocket.port | int | `3000` | |
| deployment.reloadOnChange | bool | `false` | |
| deployment.replicas | int | `1` | |
| deployment.resources.limits.memory | string | `"2Gi"` | |
| deployment.resources.requests.cpu | string | `"1"` | |
| deployment.resources.requests.memory | string | `"1Gi"` | |
| deployment.revisionHistoryLimit | int | `2` | |
| deployment.securityContext | object | `{}` | |
| deployment.strategy.type | string | `"RollingUpdate"` | |
| deployment.tolerations | list | `[]` | |
| deployment.topologySpreadConstraints | list | `[]` | |
| enterprise.enabled | bool | `false` | |
| enterprise.licenseKey | string | `""` | |
| externalSecret.enabled | bool | `false` | |
| externalSecret.files | object | `{}` | |
| externalSecret.refreshInterval | string | `"1h"` | |
| externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | |
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
| ingress.annotations | object | `{}` | |
| ingress.enabled | bool | `false` | |
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
| ingress.hosts[0].paths[0].path | string | `"/"` | |
| ingress.hosts[0].paths[0].pathType | string | `"Prefix"` | |
| ingress.hosts[0].paths[0].serviceName | string | `"formbricks"` | |
| ingress.ingressClassName | string | `"alb"` | |
| nameOverride | string | `""` | |
| partOfOverride | string | `""` | |
| postgresql.auth.database | string | `"formbricks"` | |
| postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | |
| postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | |
| postgresql.auth.username | string | `"formbricks"` | |
| postgresql.enabled | bool | `true` | |
| postgresql.externalDatabaseUrl | string | `""` | |
| postgresql.fullnameOverride | string | `"formbricks-postgresql"` | |
| postgresql.global.security.allowInsecureImages | bool | `true` | |
| postgresql.image.repository | string | `"pgvector/pgvector"` | |
| postgresql.image.tag | string | `"0.8.0-pg17"` | |
| postgresql.primary.containerSecurityContext.enabled | bool | `true` | |
| postgresql.primary.containerSecurityContext.readOnlyRootFilesystem | bool | `false` | |
| postgresql.primary.containerSecurityContext.runAsUser | int | `1001` | |
| postgresql.primary.networkPolicy.enabled | bool | `false` | |
| postgresql.primary.persistence.enabled | bool | `true` | |
| postgresql.primary.persistence.size | string | `"10Gi"` | |
| postgresql.primary.podSecurityContext.enabled | bool | `true` | |
| postgresql.primary.podSecurityContext.fsGroup | int | `1001` | |
| postgresql.primary.podSecurityContext.runAsUser | int | `1001` | |
| rbac.enabled | bool | `false` | |
| rbac.serviceAccount.additionalLabels | object | `{}` | |
| rbac.serviceAccount.annotations | object | `{}` | |
| rbac.serviceAccount.enabled | bool | `false` | |
| rbac.serviceAccount.name | string | `""` | |
| redis.architecture | string | `"standalone"` | |
| redis.auth.enabled | bool | `true` | |
| redis.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| redis.auth.existingSecretPasswordKey | string | `"REDIS_PASSWORD"` | |
| redis.enabled | bool | `true` | |
| redis.externalRedisUrl | string | `""` | |
| redis.fullnameOverride | string | `"formbricks-redis"` | |
| redis.master.persistence.enabled | bool | `true` | |
| redis.networkPolicy.enabled | bool | `false` | |
| secret.enabled | bool | `true` | |
| service.additionalLabels | object | `{}` | |
| service.annotations | object | `{}` | |
| service.enabled | bool | `true` | |
| service.ports | list | `[]` | |
| service.type | string | `"ClusterIP"` | |
| serviceMonitor.additionalLabels | string | `nil` | |
| serviceMonitor.annotations | string | `nil` | |
| serviceMonitor.enabled | bool | `true` | |
| serviceMonitor.endpoints[0].interval | string | `"5s"` | |
| serviceMonitor.endpoints[0].path | string | `"/metrics"` | |
| serviceMonitor.endpoints[0].port | string | `"metrics"` | |
---
Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2)

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,166 @@
{{ .Release.Name | camelcase }} with {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag }} has been deployed successfully on {{ template "formbricks.namespace" .}} namespace !
Here's how you can access and manage your deployment:
---
Accessing Your Application:
{{- if .Values.ingress.enabled }}
Your application is accessible via the configured Ingress.
```sh
kubectl get ingress {{ include "formbricks.name" . }} -n {{ .Release.Namespace }} -o jsonpath='{.items[*].spec.rules[*].host}' | tr ' ' '\n'
```
Ensure that your DNS points to the cluster's Ingress Controller.
{{- else if contains "LoadBalancer" .Values.service.type }}
Your application is exposed via a LoadBalancer.
Run the following to get the external IP:
```sh
kubectl get svc {{ include "formbricks.name" . }} -n {{ .Release.Namespace }}
```
{{- else if contains "NodePort" .Values.service.type }}
Your application is accessible via NodePort.
Run the following to get the assigned port:
```sh
kubectl get svc {{ include "formbricks.name" . }} -n {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}"
```
Then access the service at: `http://<NodeIP>:<NodePort>`
{{- else }}
Your application is running inside the cluster (ClusterIP service type).
To access it locally, run:
```sh
kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "formbricks.name" . }} 3000
```
Then visit: **http://localhost:3000**
{{- end }}
---
Database (PostgreSQL) Access:
{{- if .Values.postgresql.enabled }}
PostgreSQL is deployed within your cluster.
Retrieve the password using:
```sh
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "formbricks.name" . }}-app-secrets -o jsonpath="{.data.POSTGRES_USER_PASSWORD}" | base64 --decode
```
Connection details:
- **Host**: `{{ include "formbricks.name" . }}-postgresql`
- **Port**: `5432`
- **Database**: `{{ .Values.postgresql.auth.database }}`
- **Username**: `{{ .Values.postgresql.auth.username }}`
{{- else if .Values.postgresql.externalDatabaseUrl }}
You're using an external PostgreSQL database.
Connection URL:
```sh
echo "{{ .Values.postgresql.externalDatabaseUrl }}"
```
{{- end }}
---
Redis Access:
{{- if .Values.redis.enabled }}
Redis is deployed within your cluster.
Retrieve the password using:
```sh
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "formbricks.name" . }}-app-secrets -o jsonpath="{.data.REDIS_PASSWORD}" | base64 --decode
```
Connection details:
- **Host**: `{{ include "formbricks.name" . }}-redis-master`
- **Port**: `6379`
{{- else if .Values.redis.externalRedisUrl }}
You're using an external Redis instance.
Connection URL:
```sh
echo "{{ .Values.redis.externalRedisUrl }}"
```
{{- else }}
Redis is not enabled in this deployment.
{{- end }}
---
Environment Variables:
The following environment variables have been configured:
- `WEBAPP_URL`: {{ .Values.formbricks.webappUrl }}
- `NEXTAUTH_URL`: {{ .Values.formbricks.webappUrl }}
{{- if .Values.formbricks.publicUrl }}
- `PUBLIC_URL`: {{ .Values.formbricks.publicUrl }}
{{- end }}
- `NEXTAUTH_SECRET`: A random 32-character string (auto-generated)
- `ENCRYPTION_KEY`: A random 32-character string (auto-generated)
- `CRON_SECRET`: A random 32-character string (auto-generated)
- `EMAIL_VERIFICATION_DISABLED`: 1 # By Default email verification is disabled, configure SMTP settings to enable(https://formbricks.com/docs/self-hosting/configuration/smtp)
- `PASSWORD_RESET_DISABLED`: 1 # By Default password reset is disabled, configure SMTP settings to enable(https://formbricks.com/docs/self-hosting/configuration/smtp)
Retrieve them using:
```sh
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "formbricks.name" . }}-app-secrets -o jsonpath="{.data.NEXTAUTH_SECRET}" | base64 --decode
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "formbricks.name" . }}-app-secrets -o jsonpath="{.data.ENCRYPTION_KEY}" | base64 --decode
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "formbricks.name" . }}-app-secrets -o jsonpath="{.data.CRON_SECRET}" | base64 --decode
```
---
Scaling:
{{- if .Values.autoscaling.enabled }}
Horizontal Pod Autoscaling (HPA) is enabled.
- **Min Replicas**: `{{ .Values.autoscaling.minReplicas }}`
- **Max Replicas**: `{{ .Values.autoscaling.maxReplicas }}`
Check HPA status:
```sh
kubectl get hpa -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
```
{{- else }}
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.replicaCount }}` replicas.
Manually scale using:
```sh
kubectl scale deployment -n {{ .Release.Namespace }} {{ include "formbricks.name" . }} --replicas=<desired_number>
```
{{- end }}
---
External Secrets:
{{- if .Values.externalSecret.enabled }}
External secrets are enabled.
Ensure that your `SecretStore` or `ClusterSecretStore` is configured properly.
- Secret Store Name: `{{ .Values.externalSecret.secretStore.name }}`
- Refresh Interval: `{{ .Values.externalSecret.refreshInterval }}`
Verify the external secret:
```sh
kubectl get externalsecrets -n {{ .Release.Namespace }}
```
{{- else }}
External secrets are **not enabled**.
{{- end }}
---
Persistence:
{{- if .Values.postgresql.enabled }}
PostgreSQL data is persisted with `{{ .Values.postgresql.primary.persistence.size }}` storage.
{{- end }}
{{- if .Values.redis.enabled }}
Redis data is persisted with `{{ .Values.redis.master.persistence.size }}` storage..
{{- end }}
---
Formbricks Documentation and Support:
For more information, advanced configurations, and support, visit:
- **Official Website**: https://formbricks.com
- **Documentation**: https://formbricks.com/docs
For troubleshooting, refer to the documentation or community support.
---

View File

@@ -0,0 +1,141 @@
{{/*
Expand the name of the chart.
This function ensures that the chart name is either taken from `nameOverride` or defaults to `.Chart.Name`.
It also truncates the name to a maximum of 63 characters and removes trailing hyphens.
*/}}
{{- define "formbricks.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Define the application version to be used in labels.
The version is taken from `.Values.deployment.image.tag` if provided, otherwise it defaults to `.Chart.Version`.
It ensures the version only contains alphanumeric characters, underscores, dots, or hyphens, replacing any invalid characters with a hyphen.
*/}}
{{- define "formbricks.version" -}}
{{- $appVersion := default .Chart.Version .Values.deployment.image.tag -}}
{{- regexReplaceAll "[^a-zA-Z0-9_\\.\\-]" $appVersion "-" | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{/*
Generate a chart name and version string to be used in Helm chart labels.
This follows the format: `<ChartName>-<ChartVersion>`, replacing `+` with `_` and truncating to 63 characters.
*/}}
{{- define "formbricks.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels applied to Kubernetes resources.
These labels help identify and manage the application.
*/}}
{{- define "formbricks.labels" -}}
helm.sh/chart: {{ include "formbricks.chart" . }}
# Selector labels
{{ include "formbricks.selectorLabels" . }}
# Application version label
{{- with include "formbricks.version" . }}
app.kubernetes.io/version: {{ . | quote }}
{{- end }}
# Managed by Helm
app.kubernetes.io/managed-by: {{ .Release.Service }}
# Part of label, defaults to the chart name if `partOfOverride` is not provided.
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- end }}
{{/*
Selector labels used for identifying workloads in Kubernetes.
These labels ensure that selectors correctly map to the deployed resources.
*/}}
{{- define "formbricks.selectorLabels" -}}
app.kubernetes.io/name: {{ include "formbricks.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: {{ .Values.componentOverride | default (include "formbricks.name" .) }}
{{- end }}
{{/*
Renders a value that contains a Helm template.
Usage:
{{ include "formbricks.tplvalues.render" ( dict "value" .Values.path.to.the.Value "context" $) }}
This function allows rendering values dynamically.
*/}}
{{- define "formbricks.tplvalues.render" -}}
{{- if typeIs "string" .value }}
{{- tpl .value .context }}
{{- else }}
{{- tpl (.value | toYaml) .context }}
{{- end }}
{{- end }}
{{/*
Allow the release namespace to be overridden.
If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `.Release.Namespace`.
*/}}
{{- define "formbricks.namespace" -}}
{{- default .Release.Namespace .Values.namespaceOverride -}}
{{- end -}}
{{- define "formbricks.postgresAdminPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if and $secret (index $secret.data "POSTGRES_ADMIN_PASSWORD") }}
{{- index $secret.data "POSTGRES_ADMIN_PASSWORD" | b64dec -}}
{{- else }}
{{- randAlphaNum 16 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.postgresUserPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if and $secret (index $secret.data "POSTGRES_USER_PASSWORD") }}
{{- index $secret.data "POSTGRES_USER_PASSWORD" | b64dec -}}
{{- else }}
{{- randAlphaNum 16 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.redisPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if and $secret (index $secret.data "REDIS_PASSWORD") }}
{{- index $secret.data "REDIS_PASSWORD" | b64dec -}}
{{- else }}
{{- randAlphaNum 16 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if $secret }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.encryptionKey" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if $secret }}
{{- index $secret.data "ENCRYPTION_KEY" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.nextAuthSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if $secret }}
{{- index $secret.data "NEXTAUTH_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}

View File

@@ -0,0 +1,155 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.name" . }}
labels:
{{- include "formbricks.labels" . | nindent 4 }}
{{- if .Values.deployment.additionalLabels }}
{{- toYaml .Values.deployment.additionalLabels | nindent 4 }}
{{- end }}
{{- if or .Values.deployment.annotations .Values.deployment.reloadOnChange }}
annotations:
{{- if .Values.deployment.annotations }}
{{- toYaml .Values.deployment.annotations | nindent 4 }}
{{- end }}
{{- if .Values.deployment.reloadOnChange }}
reloader.stakater.com/auto: "true"
{{- end }}
{{- end }}
spec:
{{- if .Values.deployment.replicas }}
replicas: {{ .Values.deployment.replicas }}
{{- end }}
selector:
matchLabels:
{{- include "formbricks.selectorLabels" . | nindent 6 }}
{{- if .Values.deployment.strategy }}
strategy:
{{- toYaml .Values.deployment.strategy | nindent 4 }}
{{- end }}
{{- if not (kindIs "invalid" .Values.deployment.revisionHistoryLimit) }}
revisionHistoryLimit: {{ .Values.deployment.revisionHistoryLimit }}
{{- end }}
template:
metadata:
labels:
{{- include "formbricks.selectorLabels" . | nindent 8 }}
{{- if .Values.deployment.additionalPodLabels }}
{{- toYaml .Values.deployment.additionalPodLabels | nindent 8 }}
{{- end }}
{{- if .Values.deployment.disableIstioInject }}
sidecar.istio.io/inject: "false"
{{- end }}
{{- if .Values.deployment.additionalPodAnnotations }}
annotations:
{{- toYaml .Values.deployment.additionalPodAnnotations | nindent 8 }}
{{- end }}
spec:
{{- if .Values.deployment.nodeSelector }}
nodeSelector:
{{- toYaml .Values.deployment.nodeSelector | nindent 8 }}
{{- end }}
{{- if .Values.deployment.tolerations }}
tolerations:
{{- toYaml .Values.deployment.tolerations | nindent 8 }}
{{- end }}
{{- if .Values.deployment.affinity }}
affinity:
{{- toYaml .Values.deployment.affinity | nindent 8 }}
{{- end }}
{{- if .Values.deployment.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml .Values.deployment.topologySpreadConstraints | nindent 10 }}
{{- end }}
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
{{- if .Values.deployment.hostNetwork }}
hostNetwork: true
{{- end }}
{{- if .Values.rbac.serviceAccount.enabled }}
serviceAccountName: {{ .Values.rbac.serviceAccount.name | default (include "formbricks.name" .) }}
{{- end }}
{{- if .Values.deployment.securityContext }}
securityContext:
{{ toYaml .Values.deployment.securityContext | nindent 8 }}
{{- end }}
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds | default 30 }}
containers:
- name: {{ template "formbricks.name" . }}
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
{{- if .Values.deployment.command }}
command:
{{- toYaml .Values.deployment.command | nindent 12 }}
{{- end }}
{{- if .Values.deployment.args }}
args:
{{- toYaml .Values.deployment.args | nindent 12 }}
{{- end }}
{{- if .Values.deployment.ports }}
ports:
{{- range $name, $config := .Values.deployment.ports }}
- name: {{ $name | quote }}
containerPort: {{ $config.containerPort | default $config.port }}
protocol: {{ $config.protocol | default "TCP" | quote }}
{{- end }}
{{- end }}
{{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
envFrom:
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
- secretRef:
name: {{ template "formbricks.name" . }}-app-secrets
{{- end }}
{{- range $value := .Values.deployment.envFrom }}
{{- if (eq .type "configmap") }}
- configMapRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- if (eq .type "secret") }}
- secretRef:
{{- if .name }}
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
{{- else if .nameSuffix }}
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
{{- else }}
name: {{ template "formbricks.name" $ }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
env:
{{- range $key, $value := .Values.deployment.env }}
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
{{- if kindIs "string" $value }}
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
{{- else }}
{{- toYaml $value | nindent 14 }}
{{- end }}
{{- end }}
{{- if .Values.deployment.resources }}
resources:
{{- toYaml .Values.deployment.resources | nindent 12 }}
{{- end }}
{{- with .Values.deployment.probes }}
{{- if .livenessProbe }}
livenessProbe:
{{- toYaml .livenessProbe | nindent 12 }}
{{- end }}
{{- if .readinessProbe }}
readinessProbe:
{{- toYaml .readinessProbe | nindent 12 }}
{{- end }}
{{- if .startupProbe }}
startupProbe:
{{- toYaml .startupProbe | nindent 12 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,52 @@
{{- if (.Values.externalSecret).enabled }}
{{- range $nameSuffix, $data := .Values.externalSecret.files }}
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ template "formbricks.name" $ }}-{{ $nameSuffix }}
labels:
{{- include "formbricks.labels" $ | nindent 4 }}
spec:
refreshInterval: {{ $.Values.externalSecret.refreshInterval }}
{{- if and (not $data.data) (not $data.dataFrom) }}
{{- fail "Data or datafrom not specified for secret {{ template 'formbricks.name' $ }}-{{ $nameSuffix }} " }}
{{- end }}
{{- if $data.data }}
data:
{{- range $secretKey, $remoteRef := $data.data}}
- secretKey: {{ $secretKey }}
{{ toYaml $remoteRef | indent 6}}
{{- end }}
{{- end }}
{{- if $data.dataFrom }}
dataFrom:
- extract:
{{ toYaml $data.dataFrom | indent 6 }}
{{- end }}
{{- if $data.secretStore }}
secretStoreRef:
name: {{ $data.secretStore.name }}
kind: {{ $data.secretStore.kind | default "SecretStore" }}
{{- else }}
secretStoreRef:
name: {{ $.Values.externalSecret.secretStore.name }}
kind: {{ $.Values.externalSecret.secretStore.kind | default "SecretStore" }}
{{- end}}
target:
name: {{ template "formbricks.name" $ }}-{{ $nameSuffix }}
template:
type: {{ $data.type | default "Opaque" }}
{{- if or $data.annotations $data.labels}}
metadata:
{{- if $data.annotations }}
annotations:
{{ toYaml $data.annotations | indent 10 }}
{{- end }}
{{- if $data.labels }}
labels:
{{ toYaml $data.labels | indent 10 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,29 @@
{{- if .Values.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ template "formbricks.name" . }}
labels:
{{- include "formbricks.labels" . | nindent 4 }}
{{- with .Values.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if .Values.autoscaling.annotations }}
annotations:
{{- toYaml .Values.autoscaling.annotations | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ template "formbricks.name" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.autoscaling.metrics | nindent 4 }}
{{- if .Values.autoscaling.behavior }}
behavior:
{{- toYaml .Values.autoscaling.behavior | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,45 @@
{{- if (.Values.ingress).enabled -}}
{{- $applicationNameTpl := include "formbricks.name" . -}}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ template "formbricks.name" . }}
namespace: {{ include "formbricks.namespace" . }}
labels:
{{- include "formbricks.labels" $ | nindent 4 }}
{{- if .Values.ingress.additionalLabels }}
{{ toYaml .Values.ingress.additionalLabels | indent 4 }}
{{- end }}
{{- if .Values.ingress.annotations }}
annotations:
{{ toYaml .Values.ingress.annotations | indent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.ingressClassName }}
ingressClassName: {{ .Values.ingress.ingressClassName }}
{{- end}}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ tpl .host $ }}
http:
paths:
{{- if .paths }}
{{- range .paths }}
- path: {{ .path }}
pathType: {{ default "ImplementationSpecific" (.pathType) }}
backend:
service:
name: {{ default $applicationNameTpl (.serviceName) }}
port:
name: {{ default "http" (.servicePort) }}
{{- end }}
{{- else }}
{{ fail "Specify paths for ingress host, check values.yaml" }}
{{- end }}
{{- end -}}
{{- if .Values.ingress.tls }}
tls:
{{ include "formbricks.tplvalues.render" (dict "value" .Values.ingress.tls "context" $) | indent 3 }}
{{- end -}}
{{- end -}}

View File

@@ -0,0 +1,44 @@
{{- if and (.Values.secret) (.Values.secret.enabled) }}
{{- $postgresAdminPassword := include "formbricks.postgresAdminPassword" . }}
{{- $postgresUserPassword := include "formbricks.postgresUserPassword" . }}
{{- $redisPassword := include "formbricks.redisPassword" . }}
{{- $webappUrl := required "formbricks.webappUrl is required. Set it to your Formbricks instance URL (e.g., https://formbricks.example.com)" .Values.formbricks.webappUrl }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ template "formbricks.name" . }}-app-secrets
labels:
{{- include "formbricks.labels" . | nindent 4 }}
data:
# Formbricks application URLs
WEBAPP_URL: {{ $webappUrl | b64enc }}
NEXTAUTH_URL: {{ $webappUrl | b64enc }}
{{- if .Values.formbricks.publicUrl }}
PUBLIC_URL: {{ .Values.formbricks.publicUrl | b64enc }}
{{- end }}
{{- if .Values.redis.enabled }}
REDIS_URL: {{ printf "redis://:%s@formbricks-redis-master:6379" $redisPassword | b64enc }}
{{- else }}
REDIS_URL: {{ .Values.redis.externalRedisUrl | b64enc }}
{{- end }}
{{- if .Values.postgresql.enabled }}
DATABASE_URL: {{ printf "postgresql://formbricks:%s@formbricks-postgresql/formbricks" $postgresUserPassword | b64enc }}
{{- else }}
DATABASE_URL: {{ .Values.postgresql.externalDatabaseUrl | b64enc }}
{{- end }}
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
ENCRYPTION_KEY: {{ include "formbricks.encryptionKey" . | b64enc }}
NEXTAUTH_SECRET: {{ include "formbricks.nextAuthSecret" . | b64enc }}
{{- if and (.Values.enterprise.licenseKey) (ne .Values.enterprise.licenseKey "") }}
ENTERPRISE_LICENSE_KEY: {{ .Values.enterprise.licenseKey | b64enc }}
{{- end }}
{{- if .Values.redis.enabled }}
REDIS_PASSWORD: {{ $redisPassword | b64enc }}
{{- end }}
{{- if .Values.postgresql.enabled }}
POSTGRES_ADMIN_PASSWORD: {{ $postgresAdminPassword | b64enc }}
POSTGRES_USER_PASSWORD: {{ $postgresUserPassword | b64enc }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,55 @@
{{- if (.Values.service).enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ template "formbricks.name" . }}
labels:
# Standard labels for tracking the service
{{- include "formbricks.labels" . | nindent 4 }}
# Additional labels from values
{{- if .Values.service.additionalLabels }}
{{- toYaml .Values.service.additionalLabels | nindent 4 }}
{{- end }}
# Annotations for service configuration
{{- if .Values.service.annotations }}
annotations:
{{- include "formbricks.tplvalues.render" ( dict "value" .Values.service.annotations "context" $ ) | nindent 4 }}
{{- end }}
spec:
# Define the service type (ClusterIP, NodePort, LoadBalancer)
type: {{ .Values.service.type }}
# Define how the service selects pods
selector:
{{- include "formbricks.selectorLabels" . | nindent 4 }}
# Include additional pod labels if defined
{{- if .Values.deployment.podLabels }}
{{- toYaml .Values.deployment.podLabels | nindent 4 }}
{{- end }}
# Define the exposed service ports
ports:
{{- range $name, $config := .Values.deployment.ports }}
{{- if $config.exposed }}
- name: {{ $name }}
protocol: {{ default "TCP" $config.protocol | quote }}
port: {{ default $config.port $config.containerPort }}
targetPort: {{ default $config.port $config.containerPort }}
# Define NodePort if service type is NodePort
{{- if $config.nodePort }}
nodePort: {{ $config.nodePort }}
{{- end }}
{{- end }}
{{- end }}
# Include additional manually defined service ports
{{- if .Values.service.ports }}
{{- toYaml .Values.service.ports | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,22 @@
{{- if and .Values.rbac.enabled .Values.rbac.serviceAccount.enabled }}
---
apiVersion: v1
kind: ServiceAccount
metadata:
# Define the ServiceAccount name, either from values or generated
name: {{ default (include "formbricks.name" .) .Values.rbac.serviceAccount.name }}
labels:
# Standard labels for tracking the service account
{{- include "formbricks.labels" . | nindent 4 }}
# Additional labels if provided
{{- with .Values.rbac.serviceAccount.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
# Optional annotations for the service account
annotations:
{{- with .Values.rbac.serviceAccount.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,26 @@
{{- if and (.Values.serviceMonitor).enabled (.Capabilities.APIVersions.Has "monitoring.coreos.com/v1") }}
---
apiVersion: "monitoring.coreos.com/v1"
kind: ServiceMonitor
metadata:
name: {{ template "formbricks.name" . }}-svc-monitor
namespace: {{ include "formbricks.namespace" . }}
labels:
{{- include "formbricks.labels" $ | nindent 4 }}
{{- if .Values.serviceMonitor.additionalLabels }}
{{- toYaml .Values.serviceMonitor.additionalLabels | nindent 4 }}
{{- end }}
{{- if .Values.serviceMonitor.annotations }}
annotations:
{{- toYaml .Values.serviceMonitor.annotations | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{ include "formbricks.labels" $ | indent 6 }}
namespaceSelector:
matchNames:
- {{ include "formbricks.namespace" . }}
endpoints:
{{ toYaml .Values.serviceMonitor.endpoints | indent 4 }}
{{- end }}

301
helm-chart/values.yaml Normal file
View File

@@ -0,0 +1,301 @@
# Override the name for the application.
# If not set, the chart name will be used.
nameOverride: ""
# Override the name of the application component.
# Defaults to the same value as nameOverride.
componentOverride: ""
# Override the application "part-of" label.
# Defaults to the chart name if not set.
partOfOverride: ""
##########################################################
# Formbricks Configuration
##########################################################
formbricks:
# REQUIRED: Base URL of the site (e.g., https://formbricks.example.com)
# This is used for WEBAPP_URL and NEXTAUTH_URL
webappUrl: ""
# Optional: Public URL for surveys (defaults to webappUrl if not set)
publicUrl: ""
##########################################################
# Enterprise Configuration
##########################################################
enterprise:
enabled: false
licenseKey: ""
##########################################################
# Deployment Configuration
##########################################################
deployment:
# Deployment strategy configuration
strategy:
type: RollingUpdate # Type of deployment strategy (RollingUpdate/Recreate)
# rollingUpdate:
# maxSurge: 25%
# maxUnavailable: 25%
# Automatically reload deployment when ConfigMaps or Secrets change
reloadOnChange: false
# NodeSelector for scheduling pods on specific nodes
nodeSelector: {}
# Additional labels for Deployment
additionalLabels: {}
# Additional pod labels to be used in the Service's label selector
additionalPodLabels: {}
# Deployment annotations
annotations: {}
# Additional pod annotations
additionalPodAnnotations: {}
# Number of replicas
replicas: 1
# Image pull secrets for private container registries
imagePullSecrets: ""
# Environment variables from ConfigMaps or Secrets
envFrom:
# app-secrets:
# type: secret
# nameSuffix: app-secrets
# Environment variables passed to the app container
env: {}
# Tolerations for scheduling pods on tainted nodes
tolerations: []
# Pod affinity and anti-affinity rules
affinity: {}
# Topology spread constraints for better scheduling
topologySpreadConstraints: []
# Number of previous ReplicaSet versions to retain
revisionHistoryLimit: 2
# Application container image
image:
repository: "ghcr.io/formbricks/formbricks"
tag: "" # Defaults to appVersion if not set
digest: "" # If set, digest takes precedence over the tag
pullPolicy: IfNotPresent
# Health probes configuration
probes:
startupProbe:
failureThreshold: 30
periodSeconds: 10
tcpSocket:
port: 3000
readinessProbe:
failureThreshold: 5
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
initialDelaySeconds: 10
httpGet:
path: /health
port: 3000
livenessProbe:
failureThreshold: 5
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
initialDelaySeconds: 10
httpGet:
path: /health
port: 3000
# Resource requests and limits
resources:
limits:
memory: 2Gi
requests:
memory: 1Gi
cpu: "1"
# Container security context
containerSecurityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
# Pod security context
securityContext: {}
# Command override
command: []
# Arguments override
args: []
# Container ports
ports:
http:
containerPort: 3000
protocol: TCP
exposed: true
metrics:
containerPort: 9464
protocol: TCP
exposed: true
##########################################################
# Horizontal Pod Autoscaler (HPA)
##########################################################
autoscaling:
enabled: true # Enable/disable HPA
additionalLabels: {} # Additional labels for the HPA resource
annotations: {} # Annotations for HPA
minReplicas: 1 # Minimum number of replicas
maxReplicas: 10 # Maximum number of replicas
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 60
##########################################################
# Service Configuration
##########################################################
service:
enabled: true # Enable/disable Kubernetes Service
additionalLabels: {} # Additional labels for Service
annotations: {} # Annotations for Service
type: ClusterIP # Service type (ClusterIP, NodePort, LoadBalancer)
ports: [] # Additional ports
##########################################################
# Role-Based Access Control (RBAC)
##########################################################
rbac:
enabled: false # Enable/disable RBAC
serviceAccount:
enabled: false # Enable/disable ServiceAccount
name: "" # Custom ServiceAccount name
additionalLabels: {} # Additional labels
annotations: {} # Annotations
##########################################################
# Kubernetes Secret Configuration (Quick Start)
##########################################################
secret:
enabled: true
##########################################################
# External Secrets Configuration
##########################################################
externalSecret:
enabled: false # Enable/disable ExternalSecrets
secretStore:
name: aws-secrets-manager # Secret store reference name
kind: ClusterSecretStore # Type of secret store
refreshInterval: "1h" # Frequency of secret sync
files: {}
##########################################################
# Ingress Configuration
##########################################################
ingress:
enabled: false # Enable/disable Ingress
ingressClassName: alb # Specify the Ingress class
hosts:
- host: k8s.formbricks.com
paths:
- path: /
pathType: "Prefix"
serviceName: "formbricks"
annotations: {} # Ingress annotations
##########################################################
# Redis Configuration
##########################################################
redis:
enabled: true # Enable/disable Redis
externalRedisUrl: ""
fullnameOverride: "formbricks-redis"
architecture: standalone
auth:
enabled: true
existingSecret: "formbricks-app-secrets"
existingSecretPasswordKey: "REDIS_PASSWORD"
networkPolicy:
enabled: false
master:
persistence:
enabled: true
##########################################################
# Service Monitor to collect Prometheus metrices
##########################################################
serviceMonitor:
enabled: true
# Additional labels
additionalLabels:
# key: value
# Additional annotations
annotations:
# key: value
# List of the endpoints of service from which prometheus will scrape data
endpoints:
- interval: 5s
path: /metrics
port: metrics
##########################################################
# PostgreSQL Configuration
##########################################################
postgresql:
enabled: true # Enable/disable PostgreSQL
externalDatabaseUrl: ""
global:
security:
allowInsecureImages: true
fullnameOverride: "formbricks-postgresql"
image:
repository: pgvector/pgvector
tag: 0.8.0-pg17
auth:
username: formbricks
database: formbricks
existingSecret: "formbricks-app-secrets"
secretKeys:
adminPasswordKey: "POSTGRES_ADMIN_PASSWORD"
userPasswordKey: "POSTGRES_USER_PASSWORD"
primary:
networkPolicy:
enabled: false
persistence:
enabled: true
size: 10Gi
podSecurityContext:
enabled: true
fsGroup: 1001
runAsUser: 1001
containerSecurityContext:
enabled: true
runAsUser: 1001
readOnlyRootFilesystem: false

Some files were not shown because too many files have changed in this diff Show More