mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
cleanup
This commit is contained in:
@@ -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" };
|
||||
}
|
||||
});
|
||||
-92
@@ -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>
|
||||
);
|
||||
}
|
||||
-111
@@ -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>
|
||||
);
|
||||
}
|
||||
-617
@@ -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>
|
||||
);
|
||||
}
|
||||
-353
@@ -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>
|
||||
);
|
||||
}
|
||||
-47
@@ -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>
|
||||
);
|
||||
}
|
||||
-229
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
-94
@@ -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>
|
||||
);
|
||||
}
|
||||
-63
@@ -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>
|
||||
);
|
||||
}
|
||||
-35
@@ -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>
|
||||
);
|
||||
}
|
||||
-248
@@ -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>
|
||||
);
|
||||
}
|
||||
-50
@@ -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>
|
||||
);
|
||||
}
|
||||
-155
@@ -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>
|
||||
);
|
||||
}
|
||||
-28
@@ -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>
|
||||
);
|
||||
}
|
||||
-54
@@ -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>
|
||||
);
|
||||
}
|
||||
-224
@@ -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>
|
||||
);
|
||||
}
|
||||
-10
@@ -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";
|
||||
-21
@@ -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;
|
||||
-35
@@ -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";
|
||||
};
|
||||
-185
@@ -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;
|
||||
}
|
||||
}
|
||||
-171
@@ -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);
|
||||
}
|
||||
-126
@@ -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>
|
||||
);
|
||||
};
|
||||
-133
@@ -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>
|
||||
);
|
||||
}
|
||||
-28
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-431
@@ -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} />;
|
||||
}
|
||||
-42
@@ -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>
|
||||
);
|
||||
}
|
||||
-29
@@ -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>
|
||||
);
|
||||
}
|
||||
-26
@@ -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} />;
|
||||
}
|
||||
-106
@@ -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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-82
@@ -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>
|
||||
);
|
||||
}
|
||||
-154
@@ -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>
|
||||
);
|
||||
}
|
||||
-126
@@ -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>
|
||||
);
|
||||
}
|
||||
-8
@@ -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;
|
||||
}
|
||||
-18
@@ -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} />;
|
||||
}
|
||||
-76
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-84
@@ -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>
|
||||
);
|
||||
}
|
||||
-120
@@ -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>
|
||||
);
|
||||
};
|
||||
-97
@@ -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",
|
||||
|
||||
+3
-19
@@ -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} />;
|
||||
};
|
||||
|
||||
-90
@@ -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
@@ -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} />;
|
||||
}
|
||||
-256
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-47
@@ -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>
|
||||
);
|
||||
}
|
||||
-139
@@ -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`);
|
||||
}
|
||||
|
||||
+24
-9
@@ -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)",
|
||||
|
||||
-94
@@ -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>
|
||||
);
|
||||
}
|
||||
-174
@@ -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>
|
||||
);
|
||||
}
|
||||
-54
@@ -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>
|
||||
);
|
||||
}
|
||||
-178
@@ -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>
|
||||
);
|
||||
}
|
||||
-175
@@ -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";
|
||||
@@ -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`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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を接続",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+4
-2
@@ -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
@@ -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/**"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user