This commit is contained in:
pandeymangg
2026-02-18 13:23:31 +05:30
parent 1043a6f034
commit 67a2e6074e
94 changed files with 87 additions and 7713 deletions
@@ -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" };
}
});
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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";
@@ -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;
@@ -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";
};
@@ -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;
}
}
@@ -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);
}
@@ -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>
);
};
@@ -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>
);
}
@@ -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}
/>
</>
);
}
@@ -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>
);
}
@@ -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} />;
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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} />;
}
@@ -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();
}}
/>
</>
);
};
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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;
}
@@ -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} />;
}
@@ -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}
/>
</>
);
}
@@ -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>
);
}
@@ -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>
);
};
@@ -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>
);
}
@@ -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} />;
}
@@ -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>;
}
@@ -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,
})),
};
}
);
@@ -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`);
}
@@ -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;
}
@@ -1 +0,0 @@
export * from "./analysis";
@@ -2,14 +2,12 @@
import {
ArrowUpRightIcon,
BarChartIcon,
ChevronRightIcon,
Cog,
LogOutIcon,
MessageCircle,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PieChart,
RocketIcon,
ShapesIcon,
UserCircleIcon,
@@ -108,13 +106,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: "Distribute",
@@ -1,6 +1,5 @@
"use client";
import { usePathname } from "next/navigation";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface UnifyConfigNavigationProps {
@@ -14,26 +13,11 @@ export const UnifyConfigNavigation = ({
activeId: activeIdProp,
loading,
}: UnifyConfigNavigationProps) => {
const pathname = usePathname();
const activeId =
activeIdProp ??
(pathname?.includes("/unify/sources")
? "sources"
: pathname?.includes("/unify/knowledge")
? "knowledge"
: pathname?.includes("/unify/taxonomy")
? "taxonomy"
: "controls");
const baseHref = `/environments/${environmentId}/workspace/unify`;
const navigation = [
{ id: "controls", label: "Controls", href: `${baseHref}/controls` },
{ id: "sources", label: "Sources", href: `${baseHref}/sources` },
{ id: "knowledge", label: "Knowledge", href: `${baseHref}/knowledge` },
{ id: "taxonomy", label: "Taxonomy", href: `${baseHref}/taxonomy` },
];
const activeId = activeIdProp ?? "sources";
const navigation = [{ id: "sources", label: "Sources", href: `${baseHref}/sources` }];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};
@@ -1,90 +0,0 @@
"use client";
import { useState } from "react";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { Badge } from "@/modules/ui/components/badge";
import { Label } from "@/modules/ui/components/label";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
// Common languages for the base language selector
const COMMON_LANGUAGES = [
{ code: "en", label: "English" },
{ code: "de", label: "German" },
{ code: "fr", label: "French" },
{ code: "es", label: "Spanish" },
{ code: "pt", label: "Portuguese" },
{ code: "it", label: "Italian" },
{ code: "nl", label: "Dutch" },
{ code: "pl", label: "Polish" },
{ code: "ru", label: "Russian" },
{ code: "ja", label: "Japanese" },
{ code: "ko", label: "Korean" },
{ code: "zh-Hans", label: "Chinese (Simplified)" },
{ code: "zh-Hant", label: "Chinese (Traditional)" },
{ code: "ar", label: "Arabic" },
{ code: "hi", label: "Hindi" },
{ code: "tr", label: "Turkish" },
{ code: "sv", label: "Swedish" },
{ code: "no", label: "Norwegian" },
{ code: "da", label: "Danish" },
{ code: "fi", label: "Finnish" },
];
interface ControlsSectionProps {
environmentId: string;
}
export function ControlsSection({ environmentId }: ControlsSectionProps) {
const [baseLanguage, setBaseLanguage] = useState("en");
return (
<PageContentWrapper>
<PageHeader pageTitle="Unify Feedback">
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="max-w-4xl">
<SettingsCard
title="Feedback Controls"
description="Configure how feedback is processed and consolidated across all sources.">
<div className="space-y-6">
{/* Base Language Setting */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="baseLanguage">Base Language</Label>
<Badge text="AI" type="gray" size="tiny" />
</div>
<p className="text-sm text-slate-500">
All feedback will be consolidated and analyzed in this language. Feedback in other languages
will be automatically translated.
</p>
<div className="w-64">
<Select value={baseLanguage} onValueChange={setBaseLanguage}>
<SelectTrigger id="baseLanguage">
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
{COMMON_LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</SettingsCard>
</div>
</PageContentWrapper>
);
}
@@ -1 +0,0 @@
export { ControlsSection } from "./ControlsSection";
@@ -1,10 +0,0 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ControlsSection } from "./components";
export default async function UnifyControlsPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return <ControlsSection environmentId={params.environmentId} />;
}
@@ -1,256 +0,0 @@
"use client";
import { FileTextIcon, LinkIcon, PlusIcon, StickyNoteIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import type { TAllowedFileExtension } from "@formbricks/types/storage";
import { cn } from "@/lib/cn";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import type { KnowledgeItem } from "../types";
const DOC_EXTENSIONS: TAllowedFileExtension[] = ["pdf", "doc", "docx", "txt", "csv"];
const MAX_DOC_SIZE_MB = 5;
interface AddKnowledgeModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (item: KnowledgeItem) => void;
environmentId: string;
isStorageConfigured: boolean;
}
export function AddKnowledgeModal({
open,
onOpenChange,
onAdd,
environmentId,
isStorageConfigured,
}: AddKnowledgeModalProps) {
const [linkUrl, setLinkUrl] = useState("");
const [linkTitle, setLinkTitle] = useState("");
const [noteContent, setNoteContent] = useState("");
const [uploadedDocUrl, setUploadedDocUrl] = useState<string | null>(null);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const resetForm = () => {
setLinkUrl("");
setLinkTitle("");
setNoteContent("");
setUploadedDocUrl(null);
setUploadedFileName(null);
};
const handleDocUpload = async (files: File[]) => {
if (!isStorageConfigured) {
toast.error("File storage is not configured.");
return;
}
const file = files[0];
if (!file) return;
setIsUploading(true);
setUploadedDocUrl(null);
setUploadedFileName(null);
const result = await handleFileUpload(file, environmentId, DOC_EXTENSIONS);
setIsUploading(false);
if (result.error) {
toast.error("Upload failed. Please try again.");
return;
}
setUploadedDocUrl(result.url);
setUploadedFileName(file.name);
toast.success("Document uploaded. Click Add to save.");
};
const handleAddLink = () => {
if (!linkUrl.trim()) {
toast.error("Please enter a URL.");
return;
}
const now = new Date();
onAdd({
id: crypto.randomUUID(),
type: "link",
title: linkTitle.trim() || undefined,
url: linkUrl.trim(),
size: linkUrl.trim().length * 100, // Simulated size for links
createdAt: now,
indexedAt: now, // Links are indexed immediately
});
resetForm();
onOpenChange(false);
toast.success("Link added.");
};
const handleAddNote = () => {
if (!noteContent.trim()) {
toast.error("Please enter some text.");
return;
}
const now = new Date();
onAdd({
id: crypto.randomUUID(),
type: "note",
content: noteContent.trim(),
size: new Blob([noteContent.trim()]).size,
createdAt: now,
indexedAt: now, // Notes are indexed immediately
});
resetForm();
onOpenChange(false);
toast.success("Note added.");
};
const handleAddFile = () => {
if (!uploadedDocUrl) {
toast.error("Please upload a document first.");
return;
}
const now = new Date();
onAdd({
id: crypto.randomUUID(),
type: "file",
title: uploadedFileName ?? undefined,
fileUrl: uploadedDocUrl,
fileName: uploadedFileName ?? undefined,
size: Math.floor(Math.random() * 500000) + 10000, // Simulated file size (10KB - 500KB)
createdAt: now,
indexedAt: undefined, // Files take time to index - will show as "Pending"
});
resetForm();
onOpenChange(false);
toast.success("Document added.");
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) handleDocUpload(files);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
};
return (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
Add knowledge
<PlusIcon className="ml-2 size-4" />
</Button>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) resetForm();
onOpenChange(o);
}}>
<DialogContent className="sm:max-w-lg" disableCloseOnOutsideClick>
<DialogHeader>
<PlusIcon className="size-5 text-slate-600" />
<DialogTitle>Add knowledge</DialogTitle>
<DialogDescription>Add knowledge via a link, document upload, or a text note.</DialogDescription>
</DialogHeader>
<DialogBody>
<Tabs defaultValue="link" className="w-full">
<TabsList width="fill" className="mb-4 w-full">
<TabsTrigger value="link" icon={<LinkIcon className="size-4" />}>
Link
</TabsTrigger>
<TabsTrigger value="upload" icon={<FileTextIcon className="size-4" />}>
Upload doc
</TabsTrigger>
<TabsTrigger value="note" icon={<StickyNoteIcon className="size-4" />}>
Note
</TabsTrigger>
</TabsList>
<TabsContent value="link" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="link-title">Title (optional)</Label>
<Input
id="link-title"
placeholder="e.g. Product docs"
value={linkTitle}
onChange={(e) => setLinkTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="link-url">URL</Label>
<Input
id="link-url"
type="url"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
/>
</div>
<Button type="button" onClick={handleAddLink} size="sm">
Add link
</Button>
</TabsContent>
<TabsContent value="upload" className="space-y-4">
<Uploader
id="knowledge-doc-modal"
name="knowledge-doc-modal"
uploaderClassName="h-32 w-full"
allowedFileExtensions={DOC_EXTENSIONS}
multiple={false}
handleUpload={handleDocUpload}
handleDrop={handleDrop}
handleDragOver={handleDragOver}
isStorageConfigured={isStorageConfigured}
/>
<p className="text-xs text-slate-500">PDF, Word, text, or CSV. Max {MAX_DOC_SIZE_MB} MB.</p>
{isUploading && <p className="text-sm text-slate-600">Uploading</p>}
{uploadedDocUrl && (
<p className="text-sm text-slate-700">
Ready: <span className="font-medium">{uploadedFileName ?? uploadedDocUrl}</span>
</p>
)}
<Button type="button" onClick={handleAddFile} size="sm" disabled={!uploadedDocUrl}>
Add document
</Button>
</TabsContent>
<TabsContent value="note" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="knowledge-note-modal">Note</Label>
<textarea
id="knowledge-note-modal"
rows={5}
placeholder="Paste or type knowledge content here..."
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
)}
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
/>
</div>
<Button type="button" onClick={handleAddNote} size="sm">
Add note
</Button>
</TabsContent>
</Tabs>
</DialogBody>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,47 +0,0 @@
"use client";
import { useState } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import type { KnowledgeItem } from "../types";
import { AddKnowledgeModal } from "./AddKnowledgeModal";
import { KnowledgeTable } from "./KnowledgeTable";
interface KnowledgeSectionProps {
environmentId: string;
isStorageConfigured: boolean;
}
export function KnowledgeSection({ environmentId, isStorageConfigured }: KnowledgeSectionProps) {
const [items, setItems] = useState<KnowledgeItem[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const handleDeleteItem = (itemId: string) => {
setItems((prev) => prev.filter((item) => item.id !== itemId));
};
return (
<PageContentWrapper>
<PageHeader
pageTitle="Unify Feedback"
cta={
<AddKnowledgeModal
open={modalOpen}
onOpenChange={setModalOpen}
onAdd={(item) => {
setItems((prev) => [...prev, item]);
setModalOpen(false);
}}
environmentId={environmentId}
isStorageConfigured={isStorageConfigured}
/>
}>
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-6">
<KnowledgeTable items={items} onDeleteItem={handleDeleteItem} />
</div>
</PageContentWrapper>
);
}
@@ -1,139 +0,0 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { FileTextIcon, LinkIcon, MoreHorizontalIcon, StickyNoteIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { type KnowledgeItem, formatFileSize } from "../types";
interface KnowledgeTableProps {
items: KnowledgeItem[];
onDeleteItem?: (itemId: string) => void;
}
function getTypeIcon(type: KnowledgeItem["type"]) {
switch (type) {
case "link":
return <LinkIcon className="size-4 text-slate-500" />;
case "file":
return <FileTextIcon className="size-4 text-slate-500" />;
case "note":
return <StickyNoteIcon className="size-4 text-slate-500" />;
default:
return <FileTextIcon className="size-4 text-slate-500" />;
}
}
function getTypeLabel(type: KnowledgeItem["type"]) {
switch (type) {
case "link":
return "Link";
case "file":
return "Document";
case "note":
return "Note";
default:
return type;
}
}
function getTitleOrPreview(item: KnowledgeItem): string {
if (item.title) return item.title;
if (item.type === "link" && item.url) return item.url;
if (item.type === "file" && item.fileName) return item.fileName;
if (item.type === "note" && item.content) {
return item.content.length > 60 ? `${item.content.slice(0, 60)}` : item.content;
}
return "—";
}
export function KnowledgeTable({ items, onDeleteItem }: KnowledgeTableProps) {
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-5 pl-6">Name</div>
<div className="col-span-2 hidden text-center sm:block">Type</div>
<div className="col-span-2 hidden text-center sm:block">Size</div>
<div className="col-span-2 hidden text-center sm:block">Indexed At</div>
<div className="col-span-1 pr-6 text-right">Actions</div>
</div>
{items.length === 0 ? (
<p className="py-12 text-center text-sm text-slate-400">
No knowledge yet. Add a link, upload a document, or add a note.
</p>
) : (
<div className="divide-y divide-slate-100">
{items.map((item) => (
<div
key={item.id}
className="grid h-12 min-h-12 grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50">
{/* Name */}
<div className="col-span-5 flex items-center gap-3 pl-6">
{getTypeIcon(item.type)}
<div className="flex flex-col overflow-hidden">
<div className="truncate text-sm font-medium text-slate-900">{getTitleOrPreview(item)}</div>
{item.type === "link" && item.url && item.title && (
<div className="truncate text-xs text-slate-500">{item.url}</div>
)}
</div>
</div>
{/* Type */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
{getTypeLabel(item.type)}
</div>
{/* Size */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{formatFileSize(item.size)}
</div>
{/* Indexed At */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{item.indexedAt ? (
<span title={format(item.indexedAt, "PPpp")}>
{formatDistanceToNow(item.indexedAt, { addSuffix: true }).replace("about ", "")}
</span>
) : (
<span className="text-slate-400">Pending</span>
)}
</div>
{/* Actions */}
<div className="col-span-1 flex items-center justify-end pr-6">
<DropdownMenu
open={openMenuId === item.id}
onOpenChange={(open) => setOpenMenuId(open ? item.id : null)}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600 focus:bg-red-50 focus:text-red-700"
onClick={() => {
onDeleteItem?.(item.id);
setOpenMenuId(null);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
)}
</div>
);
}
@@ -1,16 +0,0 @@
import { IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { KnowledgeSection } from "./components/KnowledgeSection";
export default async function UnifyKnowledgePage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return (
<KnowledgeSection
environmentId={params.environmentId}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
);
}
@@ -1,22 +0,0 @@
export type KnowledgeItemType = "link" | "note" | "file";
export interface KnowledgeItem {
id: string;
type: KnowledgeItemType;
title?: string;
url?: string;
content?: string;
fileUrl?: string;
fileName?: string;
size?: number; // Size in bytes
createdAt: Date;
indexedAt?: Date;
}
// Format file size to human readable string
export function formatFileSize(bytes?: number): string {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -2,5 +2,5 @@ import { redirect } from "next/navigation";
export default async function UnifyPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
redirect(`/environments/${params.environmentId}/workspace/unify/controls`);
redirect(`/environments/${params.environmentId}/workspace/unify/sources`);
}
@@ -23,13 +23,21 @@ export const SOURCE_OPTIONS: TSourceOption[] = [
id: "webhook",
name: "Webhook",
description: "Receive feedback via webhook with custom mapping",
disabled: false,
disabled: true,
badge: {
text: "Coming soon",
type: "gray",
},
},
{
id: "email",
name: "Email",
description: "Import feedback from email with custom mapping",
disabled: false,
disabled: true,
badge: {
text: "Coming soon",
type: "gray",
},
},
{
id: "csv",
@@ -406,13 +414,6 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
required: false,
description: "Tenant/organization identifier for multi-tenant deployments",
},
{
id: "response_id",
name: "Response ID",
type: "string",
required: false,
description: "Groups multiple answers from a single submission",
},
{
id: "source_id",
name: "Source ID",
@@ -435,6 +436,20 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
required: false,
description: "Question text or field label for display",
},
{
id: "field_group_id",
name: "Field Group ID",
type: "string",
required: false,
description: "Stable identifier grouping related fields (for ranking, matrix, grid questions)",
},
{
id: "field_group_label",
name: "Field Group Label",
type: "string",
required: false,
description: "Human-readable question text for the group",
},
{
id: "value_text",
name: "Value (Text)",
@@ -1,94 +0,0 @@
"use client";
import { useState } from "react";
import toast from "react-hot-toast";
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 { Label } from "@/modules/ui/components/label";
export type AddKeywordModalLevel = "L1" | "L2" | "L3";
interface AddKeywordModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
level: AddKeywordModalLevel;
parentName?: string;
onConfirm: (name: string) => void;
}
export function AddKeywordModal({
open,
onOpenChange,
level,
parentName,
onConfirm,
}: AddKeywordModalProps) {
const [name, setName] = useState("");
const handleClose = (nextOpen: boolean) => {
if (!nextOpen) setName("");
onOpenChange(nextOpen);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) {
toast.error("Please enter a keyword name.");
return;
}
onConfirm(trimmed);
setName("");
onOpenChange(false);
toast.success("Keyword added (demo).");
};
const title =
level === "L1" ? "Add L1 keyword" : level === "L2" ? "Add L2 keyword" : "Add L3 keyword";
const description =
level === "L1"
? "Add a new top-level keyword."
: parentName
? `Add a new ${level} keyword under "${parentName}".`
: `Add a new ${level} keyword.`;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<DialogBody>
<div className="space-y-2">
<Label htmlFor="keyword-name">Keyword name</Label>
<Input
id="keyword-name"
placeholder="e.g. New category"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full"
/>
</div>
</DialogBody>
<DialogFooter className="m-2">
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button type="submit">Add keyword</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,174 +0,0 @@
"use client";
import {
ChevronRightIcon,
LightbulbIcon,
MessageCircleIcon,
TriangleAlertIcon,
WrenchIcon,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { Input } from "@/modules/ui/components/input";
import { H4, Small } from "@/modules/ui/components/typography";
import type { TaxonomyDetail, TaxonomyThemeItem } from "../types";
const THEME_COLORS: Record<string, string> = {
red: "bg-red-400",
orange: "bg-orange-400",
yellow: "bg-amber-400",
green: "bg-emerald-500",
slate: "bg-slate-400",
};;
function getThemeIcon(icon?: TaxonomyThemeItem["icon"]) {
switch (icon) {
case "warning":
return <TriangleAlertIcon className="size-4 text-amber-500" />;
case "wrench":
return <WrenchIcon className="size-4 text-slate-500" />;
case "message-circle":
return <MessageCircleIcon className="size-4 text-slate-500" />;
case "lightbulb":
return <LightbulbIcon className="size-4 text-amber-500" />;
default:
return <MessageCircleIcon className="size-4 text-slate-400" />;
}
}
interface ThemeItemRowProps {
item: TaxonomyThemeItem;
depth?: number;
themeSearch: string;
}
function ThemeItemRow({ item, depth = 0, themeSearch }: ThemeItemRowProps) {
const [expanded, setExpanded] = useState(depth === 0 && (item.children?.length ?? 0) > 0);
const hasChildren = item.children && item.children.length > 0;
const labelLower = item.label.toLowerCase();
const matchesSearch =
!themeSearch.trim() || labelLower.includes(themeSearch.trim().toLowerCase());
const childMatches =
hasChildren &&
item.children!.some((c) =>
c.label.toLowerCase().includes(themeSearch.trim().toLowerCase())
);
const show = matchesSearch || childMatches;
if (!show) return null;
return (
<div className="flex flex-col">
<div
className={cn(
"flex items-center gap-2 py-1.5 text-sm",
depth === 0 ? "font-medium text-slate-800" : "text-slate-600"
)}
style={{ paddingLeft: depth * 16 + 4 }}>
<button
type="button"
onClick={() => hasChildren && setExpanded(!expanded)}
className="flex shrink-0 items-center justify-center text-slate-400 hover:text-slate-600">
{hasChildren ? (
<ChevronRightIcon
className={cn("size-4 transition-transform", expanded && "rotate-90")}
/>
) : (
<span className="w-4" />
)}
</button>
{getThemeIcon(item.icon)}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<Small color="muted" className="shrink-0">
{item.count}
</Small>
</div>
{hasChildren && expanded && (
<div className="border-l border-slate-200 pl-2">
{item.children!.map((child) => (
<ThemeItemRow
key={child.id}
item={child}
depth={depth + 1}
themeSearch={themeSearch}
/>
))}
</div>
)}
</div>
);
}
interface TaxonomyDetailPanelProps {
detail: TaxonomyDetail | null;
}
export function TaxonomyDetailPanel({ detail }: TaxonomyDetailPanelProps) {
const [themeSearch, setThemeSearch] = useState("");
if (!detail) {
return (
<div className="flex flex-1 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<Small color="muted">Select a Level 3 keyword to view details.</Small>
</div>
</div>
);
}
const totalThemes = detail.themes.reduce((s, t) => s + t.count, 0);
return (
<div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-col gap-5 overflow-y-auto p-4">
<div className="border-b border-slate-200 pb-4">
<H4 className="mb-1">{detail.keywordName}</H4>
<div className="flex items-center gap-2">
<Small color="muted">{detail.count} responses</Small>
<button
type="button"
className="text-sm font-medium text-slate-600 underline-offset-2 hover:underline">
View all
</button>
</div>
</div>
<div>
<H4 className="mb-1 text-sm">Description</H4>
<Small color="muted" className="leading-relaxed">
{detail.description}
</Small>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<H4 className="text-sm">{detail.themes.length} themes</H4>
<div className="flex h-2 flex-1 max-w-[120px] overflow-hidden rounded-full bg-slate-100">
{detail.themes.map((t) => (
<div
key={t.id}
className={cn(THEME_COLORS[t.color] ?? "bg-slate-400")}
style={{
width: totalThemes ? `${(t.count / totalThemes) * 100}%` : "0%",
}}
title={t.label}
/>
))}
</div>
</div>
<Input
placeholder="Search themes"
value={themeSearch}
onChange={(e) => setThemeSearch(e.target.value)}
className="mb-3 h-9 text-sm"
/>
<div className="space-y-0.5">
{detail.themeItems.map((item) => (
<ThemeItemRow key={item.id} item={item} themeSearch={themeSearch} />
))}
</div>
</div>
</div>
</div>
);
}
@@ -1,54 +0,0 @@
"use client";
import { cn } from "@/lib/cn";
import { formatCount } from "../lib/mock-data";
import type { TaxonomyKeyword } from "../types";
import { Button } from "@/modules/ui/components/button";
interface TaxonomyKeywordColumnProps {
title: string;
keywords: TaxonomyKeyword[];
selectedId: string | null;
onSelect: (id: string) => void;
addButtonLabel?: string;
onAdd?: () => void;
}
export function TaxonomyKeywordColumn({
title,
keywords,
selectedId,
onSelect,
addButtonLabel,
onAdd,
}: TaxonomyKeywordColumnProps) {
return (
<div className="flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-4 py-3">
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
</div>
<div className="flex min-h-[320px] flex-1 flex-col overflow-y-auto">
{keywords.map((kw) => (
<button
key={kw.id}
type="button"
onClick={() => onSelect(kw.id)}
className={cn(
"grid w-full grid-cols-[1fr,auto] content-center gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors last:border-b-0",
selectedId === kw.id ? "bg-slate-50" : "hover:bg-slate-50"
)}>
<span className="min-w-0 truncate text-sm font-medium text-slate-800">{kw.name}</span>
<span className="text-sm text-slate-500">{formatCount(kw.count)}</span>
</button>
))}
{addButtonLabel && (
<div className="border-t border-slate-200 p-2">
<Button type="button" variant="outline" size="sm" className="w-full" onClick={onAdd}>
+ {addButtonLabel}
</Button>
</div>
)}
</div>
</div>
);
}
@@ -1,178 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { Input } from "@/modules/ui/components/input";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import { getDetailForL3, getL2Keywords, getL3Keywords, MOCK_LEVEL1_KEYWORDS } from "../lib/mock-data";
import type { TaxonomyKeyword } from "../types";
import { AddKeywordModal } from "./AddKeywordModal";
import { TaxonomyDetailPanel } from "./TaxonomyDetailPanel";
import { TaxonomyKeywordColumn } from "./TaxonomyKeywordColumn";
interface TaxonomySectionProps {
environmentId: string;
}
export function TaxonomySection({ environmentId }: TaxonomySectionProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedL1Id, setSelectedL1Id] = useState<string | null>("l1-1");
const [selectedL2Id, setSelectedL2Id] = useState<string | null>("l2-1a");
const [selectedL3Id, setSelectedL3Id] = useState<string | null>("l3-1a");
const [addKeywordModalOpen, setAddKeywordModalOpen] = useState(false);
const [addKeywordModalLevel, setAddKeywordModalLevel] = useState<"L1" | "L2" | "L3">("L1");
const [customL1Keywords, setCustomL1Keywords] = useState<TaxonomyKeyword[]>([]);
const [customL2Keywords, setCustomL2Keywords] = useState<TaxonomyKeyword[]>([]);
const [customL3Keywords, setCustomL3Keywords] = useState<TaxonomyKeyword[]>([]);
const l2Keywords = useMemo(() => {
const fromMock = selectedL1Id ? getL2Keywords(selectedL1Id) : [];
const custom = customL2Keywords.filter((k) => k.parentId === selectedL1Id);
return [...fromMock, ...custom];
}, [selectedL1Id, customL2Keywords]);
const l3Keywords = useMemo(() => {
const fromMock = selectedL2Id ? getL3Keywords(selectedL2Id) : [];
const custom = customL3Keywords.filter((k) => k.parentId === selectedL2Id);
return [...fromMock, ...custom];
}, [selectedL2Id, customL3Keywords]);
const detail = useMemo(
() => (selectedL3Id ? getDetailForL3(selectedL3Id) : null),
[selectedL3Id]
);
const l1Keywords = useMemo(
() => [...MOCK_LEVEL1_KEYWORDS, ...customL1Keywords],
[customL1Keywords]
);
const filterKeywords = (list: TaxonomyKeyword[], q: string) => {
if (!q.trim()) return list;
const lower = q.trim().toLowerCase();
return list.filter((k) => k.name.toLowerCase().includes(lower));
};
const filteredL1 = useMemo(
() => filterKeywords(l1Keywords, searchQuery),
[l1Keywords, searchQuery]
);
const filteredL2 = useMemo(() => filterKeywords(l2Keywords, searchQuery), [l2Keywords, searchQuery]);
const filteredL3 = useMemo(() => filterKeywords(l3Keywords, searchQuery), [l3Keywords, searchQuery]);
const selectedL1Name = useMemo(
() => l1Keywords.find((k) => k.id === selectedL1Id)?.name,
[l1Keywords, selectedL1Id]
);
const selectedL2Name = useMemo(
() => l2Keywords.find((k) => k.id === selectedL2Id)?.name,
[l2Keywords, selectedL2Id]
);
const addKeywordParentName =
addKeywordModalLevel === "L2" ? selectedL1Name : addKeywordModalLevel === "L3" ? selectedL2Name : undefined;
const handleAddKeyword = (name: string) => {
if (addKeywordModalLevel === "L1") {
const id = `custom-l1-${crypto.randomUUID()}`;
setCustomL1Keywords((prev) => [...prev, { id, name, count: 0 }]);
setSelectedL1Id(id);
setSelectedL2Id(null);
setSelectedL3Id(null);
} else if (addKeywordModalLevel === "L2" && selectedL1Id) {
const id = `custom-l2-${crypto.randomUUID()}`;
setCustomL2Keywords((prev) => [
...prev,
{ id, name, count: 0, parentId: selectedL1Id },
]);
setSelectedL2Id(id);
setSelectedL3Id(null);
} else if (addKeywordModalLevel === "L3" && selectedL2Id) {
const id = `custom-l3-${crypto.randomUUID()}`;
setCustomL3Keywords((prev) => [
...prev,
{ id, name, count: 0, parentId: selectedL2Id },
]);
setSelectedL3Id(id);
}
};
return (
<PageContentWrapper>
<PageHeader pageTitle="Unify Feedback">
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Input
placeholder="Find in taxonomy..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-md"
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
<div className="lg:col-span-3">
<TaxonomyKeywordColumn
title={`Level 1 Keywords (${filteredL1.length})`}
keywords={filteredL1}
selectedId={selectedL1Id}
onSelect={(id) => {
setSelectedL1Id(id);
setSelectedL2Id(null);
setSelectedL3Id(null);
}}
addButtonLabel="Add L1 Keyword"
onAdd={() => {
setAddKeywordModalLevel("L1");
setAddKeywordModalOpen(true);
}}
/>
</div>
<div className="lg:col-span-3">
<TaxonomyKeywordColumn
title={`Level 2 Keywords (${filteredL2.length})`}
keywords={filteredL2}
selectedId={selectedL2Id}
onSelect={(id) => {
setSelectedL2Id(id);
setSelectedL3Id(null);
}}
addButtonLabel="Add L2 Keyword"
onAdd={() => {
setAddKeywordModalLevel("L2");
setAddKeywordModalOpen(true);
}}
/>
</div>
<div className="lg:col-span-3">
<TaxonomyKeywordColumn
title={`Level 3 Keywords (${filteredL3.length})`}
keywords={filteredL3}
selectedId={selectedL3Id}
onSelect={setSelectedL3Id}
addButtonLabel="Add L3 Keyword"
onAdd={() => {
setAddKeywordModalLevel("L3");
setAddKeywordModalOpen(true);
}}
/>
</div>
<div className="min-h-[400px] lg:col-span-3">
<TaxonomyDetailPanel detail={detail} />
</div>
</div>
</div>
<AddKeywordModal
open={addKeywordModalOpen}
onOpenChange={setAddKeywordModalOpen}
level={addKeywordModalLevel}
parentName={addKeywordParentName}
onConfirm={handleAddKeyword}
/>
</PageContentWrapper>
);
}
@@ -1,175 +0,0 @@
import type { TaxonomyDetail, TaxonomyKeyword, TaxonomyThemeItem } from "../types";
export const MOCK_LEVEL1_KEYWORDS: TaxonomyKeyword[] = [
{ id: "l1-1", name: "Dashboard", count: 12400 },
{ id: "l1-2", name: "Usability", count: 8200 },
{ id: "l1-3", name: "Performance", count: 5600 },
{ id: "l1-4", name: "Miscellaneous", count: 3100 },
];
export const MOCK_LEVEL2_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
"l1-1": [
{ id: "l2-1a", name: "Survey overview", count: 5200, parentId: "l1-1" },
{ id: "l2-2a", name: "Response metrics", count: 3800, parentId: "l1-1" },
{ id: "l2-3a", name: "Analytics & reports", count: 2400, parentId: "l1-1" },
{ id: "l2-4a", name: "Widgets & embedding", count: 800, parentId: "l1-1" },
{ id: "l2-5a", name: "Not specified", count: 200, parentId: "l1-1" },
],
"l1-2": [
{ id: "l2-1b", name: "Survey builder", count: 3200, parentId: "l1-2" },
{ id: "l2-2b", name: "Question types", count: 2100, parentId: "l1-2" },
{ id: "l2-3b", name: "Logic & branching", count: 1400, parentId: "l1-2" },
{ id: "l2-4b", name: "Styling & theming", count: 900, parentId: "l1-2" },
{ id: "l2-5b", name: "Not specified", count: 600, parentId: "l1-2" },
],
"l1-3": [
{ id: "l2-1c", name: "Load time & speed", count: 2200, parentId: "l1-3" },
{ id: "l2-2c", name: "Survey rendering", count: 1600, parentId: "l1-3" },
{ id: "l2-3c", name: "SDK & integration", count: 1100, parentId: "l1-3" },
{ id: "l2-4c", name: "API & data sync", count: 500, parentId: "l1-3" },
{ id: "l2-5c", name: "Not specified", count: 200, parentId: "l1-3" },
],
"l1-4": [
{ id: "l2-1d", name: "Feature requests", count: 1500, parentId: "l1-4" },
{ id: "l2-2d", name: "Bug reports", count: 900, parentId: "l1-4" },
{ id: "l2-3d", name: "Documentation", count: 400, parentId: "l1-4" },
{ id: "l2-4d", name: "Not specified", count: 300, parentId: "l1-4" },
],
};
export const MOCK_LEVEL3_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
"l2-1a": [
{ id: "l3-1a", name: "In-app surveys", count: 2800, parentId: "l2-1a" },
{ id: "l3-2a", name: "Link surveys", count: 1600, parentId: "l2-1a" },
{ id: "l3-3a", name: "Response summary", count: 600, parentId: "l2-1a" },
{ id: "l3-4a", name: "Not specified", count: 200, parentId: "l2-1a" },
],
"l2-2a": [
{ id: "l3-5a", name: "Completion rate", count: 1800, parentId: "l2-2a" },
{ id: "l3-6a", name: "Drop-off points", count: 1200, parentId: "l2-2a" },
{ id: "l3-7a", name: "Response distribution", count: 800, parentId: "l2-2a" },
],
"l2-1b": [
{ id: "l3-1b", name: "Drag & drop editor", count: 1400, parentId: "l2-1b" },
{ id: "l3-2b", name: "Question configuration", count: 900, parentId: "l2-1b" },
{ id: "l3-3b", name: "Multi-language surveys", count: 500, parentId: "l2-1b" },
{ id: "l3-4b", name: "Not specified", count: 400, parentId: "l2-1b" },
],
"l2-2b": [
{ id: "l3-5b", name: "Open text & NPS", count: 1100, parentId: "l2-2b" },
{ id: "l3-6b", name: "Multiple choice & rating", count: 600, parentId: "l2-2b" },
{ id: "l3-7b", name: "File upload & date picker", count: 400, parentId: "l2-2b" },
],
"l2-1c": [
{ id: "l3-1c", name: "Widget initialization", count: 900, parentId: "l2-1c" },
{ id: "l3-2c", name: "Survey load delay", count: 700, parentId: "l2-1c" },
{ id: "l3-3c", name: "Bundle size impact", count: 600, parentId: "l2-1c" },
],
"l2-1d": [
{ id: "l3-1d", name: "New question types", count: 600, parentId: "l2-1d" },
{ id: "l3-2d", name: "Integrations & webhooks", count: 500, parentId: "l2-1d" },
{ id: "l3-3d", name: "Export & reporting", count: 400, parentId: "l2-1d" },
],
};
export function getL2Keywords(parentL1Id: string): TaxonomyKeyword[] {
return MOCK_LEVEL2_KEYWORDS[parentL1Id] ?? [];
}
export function getL3Keywords(parentL2Id: string): TaxonomyKeyword[] {
return MOCK_LEVEL3_KEYWORDS[parentL2Id] ?? [];
}
export const MOCK_DETAIL_L3: Record<string, TaxonomyDetail> = {
"l3-1a": {
keywordId: "l3-1a",
keywordName: "In-app surveys",
count: 2800,
description:
"Feedback collected directly inside your product. Formbricks in-app surveys are triggered by actions (e.g. page view, click) and can be shown as modal, full-width, or inline widgets.",
themes: [
{ id: "t1", label: "Issues", count: 1200, color: "red" },
{ id: "t2", label: "Ideas", count: 900, color: "orange" },
{ id: "t3", label: "Questions", count: 500, color: "yellow" },
{ id: "t4", label: "Other", count: 200, color: "green" },
],
themeItems: [
{
id: "ti-1",
label: "Survey not showing on trigger",
count: 420,
icon: "warning",
children: [
{ id: "ti-1-1", label: "Wrong environment or survey ID", count: 200 },
{ id: "ti-1-2", label: "Trigger conditions not met", count: 150 },
{ id: "ti-1-3", label: "SDK not loaded in time", count: 70 },
],
},
{ id: "ti-2", label: "Positioning and placement", count: 310, icon: "wrench" },
{ id: "ti-3", label: "Request for more trigger types", count: 280, icon: "lightbulb" },
{ id: "ti-4", label: "Miscellaneous in-app feedback", count: 190, icon: "message-circle" },
],
},
"l3-1b": {
keywordId: "l3-1b",
keywordName: "Drag & drop editor",
count: 1400,
description:
"The Formbricks survey builder lets you add and reorder questions with drag and drop, configure question settings, and preview surveys before publishing.",
themes: [
{ id: "t1", label: "Issues", count: 600, color: "red" },
{ id: "t2", label: "Ideas", count: 500, color: "orange" },
{ id: "t3", label: "Questions", count: 250, color: "yellow" },
{ id: "t4", label: "Other", count: 50, color: "green" },
],
themeItems: [
{ id: "ti-1", label: "Reordering fails with many questions", count: 220, icon: "warning" },
{ id: "ti-2", label: "Request for keyboard shortcuts", count: 180, icon: "lightbulb" },
{ id: "ti-3", label: "Undo / redo in editor", count: 150, icon: "lightbulb" },
{ id: "ti-4", label: "Miscellaneous builder feedback", count: 100, icon: "message-circle" },
],
},
"l3-1c": {
keywordId: "l3-1c",
keywordName: "Widget initialization",
count: 900,
description:
"How quickly the Formbricks widget loads and becomes ready to display surveys. Includes script load time, SDK init, and first-paint for survey UI.",
themes: [
{ id: "t1", label: "Issues", count: 550, color: "red" },
{ id: "t2", label: "Ideas", count: 250, color: "orange" },
{ id: "t3", label: "Questions", count: 100, color: "yellow" },
{ id: "t4", label: "Other", count: 0, color: "green" },
],
themeItems: [
{ id: "ti-1", label: "Slow init on mobile networks", count: 280, icon: "warning" },
{ id: "ti-2", label: "Blocking main thread", count: 180, icon: "warning" },
{ id: "ti-3", label: "Lazy-load SDK suggestion", count: 120, icon: "lightbulb" },
],
},
"l3-1d": {
keywordId: "l3-1d",
keywordName: "New question types",
count: 600,
description:
"Requests for additional question types in Formbricks surveys (e.g. matrix, ranking, sliders, image choice) to capture different kinds of feedback.",
themes: [
{ id: "t1", label: "Ideas", count: 450, color: "orange" },
{ id: "t2", label: "Questions", count: 100, color: "yellow" },
{ id: "t3", label: "Other", count: 50, color: "green" },
],
themeItems: [
{ id: "ti-1", label: "Matrix / grid question", count: 180, icon: "lightbulb" },
{ id: "ti-2", label: "Ranking question type", count: 120, icon: "lightbulb" },
{ id: "ti-3", label: "Slider and scale variants", count: 90, icon: "lightbulb" },
],
},
};
export function getDetailForL3(keywordId: string): TaxonomyDetail | null {
return MOCK_DETAIL_L3[keywordId] ?? null;
}
export function formatCount(n: number): string {
return n.toLocaleString();
}
@@ -1,10 +0,0 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TaxonomySection } from "./components/TaxonomySection";
export default async function UnifyTaxonomyPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return <TaxonomySection environmentId={params.environmentId} />;
}
@@ -1,30 +0,0 @@
export interface TaxonomyKeyword {
id: string;
name: string;
count: number;
parentId?: string;
}
export interface TaxonomyTheme {
id: string;
label: string;
count: number;
color: "red" | "orange" | "yellow" | "green" | "slate";
}
export interface TaxonomyThemeItem {
id: string;
label: string;
count: number;
icon?: "warning" | "wrench" | "message-circle" | "lightbulb";
children?: TaxonomyThemeItem[];
}
export interface TaxonomyDetail {
keywordId: string;
keywordName: string;
count: number;
description: string;
themes: TaxonomyTheme[];
themeItems: TaxonomyThemeItem[];
}
@@ -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;
}
}
@@ -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";
-39
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;
}
@@ -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`,
},
},
});
-246
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 });
}
}
-3
View File
@@ -4,8 +4,6 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
TConnector,
TConnectorFieldMapping,
TConnectorFormbricksMapping,
TConnectorWithMappings,
ZConnectorCreateInput,
ZConnectorFieldMappingCreateInput,
@@ -28,7 +26,6 @@ import {
deleteConnector,
deleteFieldMapping,
deleteFormbricksMapping,
getConnector,
getConnectorWithMappings,
getConnectors,
getConnectorsWithMappings,
+21 -19
View File
@@ -1,9 +1,6 @@
import "server-only";
import { logger } from "@formbricks/logger";
// Hub API base URL - should be configurable via environment variable
const HUB_API_URL = process.env.HUB_API_URL || "http://localhost:8080";
const HUB_API_KEY = process.env.HUB_API_KEY || "";
import { HUB_API_KEY, HUB_API_URL } from "@/lib/constants";
// Hub field types (from OpenAPI spec)
export type THubFieldType =
@@ -19,15 +16,16 @@ export type THubFieldType =
// Create FeedbackRecord input
export interface TCreateFeedbackRecordInput {
collected_at?: string; // ISO 8601 datetime, defaults to now
source_type: string; // Required
field_id: string; // Required
field_type: THubFieldType; // Required
collected_at?: string;
source_type: string;
field_id: string;
field_type: THubFieldType;
field_label?: string;
field_group_id?: string;
field_group_label?: string;
tenant_id?: string;
response_id?: string;
source_id?: string;
source_name?: string;
field_label?: string;
value_text?: string;
value_number?: number;
value_boolean?: boolean;
@@ -45,12 +43,13 @@ export interface TFeedbackRecordData {
updated_at: string;
source_type: string;
field_id: string;
field_type: string;
field_type: THubFieldType;
field_label?: string;
field_group_id?: string;
field_group_label?: string;
tenant_id?: string;
response_id?: string;
source_id?: string;
source_name?: string;
field_label?: string;
value_text?: string;
value_number?: number;
value_boolean?: boolean;
@@ -82,14 +81,14 @@ export interface TUpdateFeedbackRecordInput {
// List FeedbackRecords filters
export interface TListFeedbackRecordsFilters {
tenant_id?: string;
response_id?: string;
source_type?: string;
source_id?: string;
field_id?: string;
field_type?: string;
field_group_id?: string;
field_type?: THubFieldType;
user_identifier?: string;
since?: string; // ISO 8601
until?: string; // ISO 8601
since?: string;
until?: string;
limit?: number;
offset?: number;
}
@@ -176,7 +175,10 @@ async function hubFetch<T>(
return { data: null, error: null };
} catch (error) {
logger.error("Hub API request failed", { url, error });
logger.error(
{ url, error: error instanceof Error ? error.message : "Unknown error" },
"Hub API request failed"
);
return {
data: null,
error: new HubApiError({
@@ -221,10 +223,10 @@ export async function listFeedbackRecords(
const searchParams = new URLSearchParams();
if (filters.tenant_id) searchParams.set("tenant_id", filters.tenant_id);
if (filters.response_id) searchParams.set("response_id", filters.response_id);
if (filters.source_type) searchParams.set("source_type", filters.source_type);
if (filters.source_id) searchParams.set("source_id", filters.source_id);
if (filters.field_id) searchParams.set("field_id", filters.field_id);
if (filters.field_group_id) searchParams.set("field_group_id", filters.field_group_id);
if (filters.field_type) searchParams.set("field_type", filters.field_type);
if (filters.user_identifier) searchParams.set("user_identifier", filters.user_identifier);
if (filters.since) searchParams.set("since", filters.since);
+21 -13
View File
@@ -7,10 +7,16 @@ import { TCreateFeedbackRecordInput, THubFieldType as THubClientFieldType } from
// Response data value types
type TResponseValue = string | number | string[] | Record<string, string> | undefined;
function stripHtmlTags(html: string): string {
return html.replace(/<[^>]*>/g, "").trim();
}
/**
* Get the headline of an element from a survey
* Get the headline of an element from a survey, with HTML tags stripped
*/
function getElementHeadline(survey: TSurvey, elementId: string): string {
let raw = "Untitled";
// Try to find in blocks first
if (survey.blocks && survey.blocks.length > 0) {
for (const block of survey.blocks) {
@@ -19,9 +25,9 @@ function getElementHeadline(survey: TSurvey, elementId: string): string {
if (element.id === elementId) {
const headline = element.headline;
if (!headline) return "Untitled";
if (typeof headline === "string") return headline;
if (typeof headline === "object" && headline.default) return headline.default;
return "Untitled";
if (typeof headline === "string") raw = headline;
else if (typeof headline === "object" && headline.default) raw = headline.default;
return stripHtmlTags(raw) || "Untitled";
}
}
}
@@ -37,9 +43,9 @@ function getElementHeadline(survey: TSurvey, elementId: string): string {
if (question.id === elementId) {
const headline = question.headline;
if (!headline) return "Untitled";
if (typeof headline === "string") return headline;
if (typeof headline === "object" && headline.default) return headline.default;
return "Untitled";
if (typeof headline === "string") raw = headline;
else if (typeof headline === "object" && headline.default) raw = headline.default;
return stripHtmlTags(raw) || "Untitled";
}
}
}
@@ -178,26 +184,28 @@ export function transformResponseToFeedbackRecords(
// Convert value to appropriate Hub fields
const valueFields = convertValueToHubFields(value, mapping.hubFieldType as THubFieldType);
// Create the FeedbackRecord payload
// Build the FeedbackRecord payload, only including defined values
const feedbackRecord: TCreateFeedbackRecordInput = {
collected_at: response.createdAt.toISOString(),
collected_at:
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
source_type: "formbricks",
field_id: mapping.elementId,
field_type: mapping.hubFieldType as THubClientFieldType,
source_id: survey.id,
source_name: survey.name,
field_label: fieldLabel,
response_id: response.id,
language: response.language || undefined,
...valueFields,
};
// Add tenant ID if provided
// Only add optional string fields if they have a truthy value
if (response.language && response.language !== "default") {
feedbackRecord.language = response.language;
}
if (tenantId) {
feedbackRecord.tenant_id = tenantId;
}
// Add user identifier if available
if (response.contactId) {
feedbackRecord.user_identifier = response.contactId;
}
+3
View File
@@ -41,6 +41,9 @@ export const GITHUB_SECRET = env.GITHUB_SECRET;
export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID;
export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET;
export const HUB_API_URL = env.HUB_API_URL || "http://localhost:8080";
export const HUB_API_KEY = env.HUB_API_KEY;
export const AZUREAD_CLIENT_ID = env.AZUREAD_CLIENT_ID;
export const AZUREAD_CLIENT_SECRET = env.AZUREAD_CLIENT_SECRET;
export const AZUREAD_TENANT_ID = env.AZUREAD_TENANT_ID;
+4
View File
@@ -33,6 +33,8 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
HTTP_PROXY: z.string().url().optional(),
HTTPS_PROXY: z.string().url().optional(),
HUB_API_URL: z.string().url().optional(),
HUB_API_KEY: z.string().optional(),
IMPRINT_URL: z
.string()
.url()
@@ -162,6 +164,8 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
HUB_API_URL: process.env.HUB_API_URL,
HUB_API_KEY: process.env.HUB_API_KEY,
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,
-2
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",
@@ -164,7 +163,6 @@
"code": "Code",
"collapse_rows": "Zeilen einklappen",
"completed": "Abgeschlossen",
"configuration": "Konfiguration",
"confirm": "Bestätigen",
"connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden",
-2
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",
@@ -164,7 +163,6 @@
"code": "Code",
"collapse_rows": "Collapse rows",
"completed": "Completed",
"configuration": "Configuration",
"confirm": "Confirm",
"connect": "Connect",
"connect_formbricks": "Connect Formbricks",
-2
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",
@@ -164,7 +163,6 @@
"code": "Código",
"collapse_rows": "Contraer filas",
"completed": "Completado",
"configuration": "Configuración",
"confirm": "Confirmar",
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
-2
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",
@@ -164,7 +163,6 @@
"code": "Code",
"collapse_rows": "Réduire les lignes",
"completed": "Terminé",
"configuration": "Configuration",
"confirm": "Confirmer",
"connect": "Connecter",
"connect_formbricks": "Connecter Formbricks",
-2
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": "匿名",
@@ -164,7 +163,6 @@
"code": "コード",
"collapse_rows": "行を非表示",
"completed": "完了",
"configuration": "設定",
"confirm": "確認",
"connect": "接続",
"connect_formbricks": "Formbricksを接続",
-2
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",
@@ -164,7 +163,6 @@
"code": "Code",
"collapse_rows": "Rijen samenvouwen",
"completed": "Voltooid",
"configuration": "Configuratie",
"confirm": "Bevestigen",
"connect": "Verbinden",
"connect_formbricks": "Sluit Formbricks aan",
-2
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",
@@ -164,7 +163,6 @@
"code": "Código",
"collapse_rows": "Recolher linhas",
"completed": "Concluído",
"configuration": "Configuração",
"confirm": "Confirmar",
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
-2
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",
@@ -164,7 +163,6 @@
"code": "Código",
"collapse_rows": "Recolher linhas",
"completed": "Concluído",
"configuration": "Configuração",
"confirm": "Confirmar",
"connect": "Conectar",
"connect_formbricks": "Ligar Formbricks",
-2
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",
@@ -164,7 +163,6 @@
"code": "Cod",
"collapse_rows": "Restrânge rânduri",
"completed": "Completat",
"configuration": "Configurare",
"confirm": "Confirmare",
"connect": "Conectează",
"connect_formbricks": "Conectează Formbricks",
-2
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": "Аноним",
@@ -164,7 +163,6 @@
"code": "Код",
"collapse_rows": "Свернуть строки",
"completed": "Завершено",
"configuration": "Конфигурация",
"confirm": "Подтвердить",
"connect": "Подключить",
"connect_formbricks": "Подключить Formbricks",
-2
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",
@@ -164,7 +163,6 @@
"code": "Kod",
"collapse_rows": "Dölj rader",
"completed": "Slutförd",
"configuration": "Konfiguration",
"confirm": "Bekräfta",
"connect": "Anslut",
"connect_formbricks": "Anslut Formbricks",
-2
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": "匿名",
@@ -164,7 +163,6 @@
"code": "代码",
"collapse_rows": "折叠 行",
"completed": "完成",
"configuration": "配置",
"confirm": "确认",
"connect": "连接",
"connect_formbricks": "连接 Formbricks",
-2
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": "匿名",
@@ -164,7 +163,6 @@
"code": "程式碼",
"collapse_rows": "摺疊列",
"completed": "已完成",
"configuration": "組態",
"confirm": "確認",
"connect": "連線",
"connect_formbricks": "連線 Formbricks",
-1
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",
-15
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;
},
};
-156
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`,
},
},
});
-26
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
+3 -2
View File
@@ -28,11 +28,12 @@ export const ZHubTargetField = z.enum([
"source_type",
"field_id",
"field_type",
"field_label",
"field_group_id",
"field_group_label",
"tenant_id",
"response_id",
"source_id",
"source_name",
"field_label",
"value_text",
"value_number",
"value_boolean",
+4 -2
View File
@@ -7624,16 +7624,18 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
@@ -10430,7 +10432,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
tarn@3.0.2:
resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==}
+3 -1
View File
@@ -247,7 +247,9 @@
"UNSPLASH_ACCESS_KEY",
"PROMETHEUS_ENABLED",
"PROMETHEUS_EXPORTER_PORT",
"USER_MANAGEMENT_MINIMUM_ROLE"
"USER_MANAGEMENT_MINIMUM_ROLE",
"HUB_API_URL",
"HUB_API_KEY"
],
"outputs": ["dist/**", ".next/**"]
},