mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-03 13:37:26 -06:00
Compare commits
13 Commits
typeerror-
...
feat/unify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1043a6f034 | ||
|
|
f7a28f8cb3 | ||
|
|
ac8cf7c6b4 | ||
|
|
39e7de7e91 | ||
|
|
36955ddbb8 | ||
|
|
ef56e97c95 | ||
|
|
ea3b4b9413 | ||
|
|
131a04b77c | ||
|
|
ca7e2c64de | ||
|
|
e4bd9a839a | ||
|
|
590c85d1ca | ||
|
|
39c99baaac | ||
|
|
238b2adf3f |
12
.env.example
12
.env.example
@@ -225,4 +225,14 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
|
||||
# ##############################################
|
||||
# Formbricks Hub API Configuration (for Unify connectors)
|
||||
# ##############################################
|
||||
|
||||
# The base URL of the Formbricks Hub API
|
||||
# HUB_API_URL=http://localhost:8080
|
||||
|
||||
# API key for authenticating with the Hub API
|
||||
# HUB_API_KEY=
|
||||
@@ -0,0 +1,520 @@
|
||||
"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" };
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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";
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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";
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
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`);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./analysis";
|
||||
@@ -36,7 +36,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
// Calculate derived values (no queries)
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
@@ -63,7 +63,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
locale={user.locale}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BarChartIcon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PieChart,
|
||||
RocketIcon,
|
||||
ShapesIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -99,20 +102,33 @@ export const MainNavigation = ({
|
||||
const mainNavigation = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
name: "Ask",
|
||||
href: `/environments/${environment.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/environments/${environment.id}/analysis`,
|
||||
icon: PieChart,
|
||||
isActive: pathname?.includes("/analysis"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
href: `/environments/${environment.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
name: "Distribute",
|
||||
icon: UserIcon,
|
||||
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
name: "Unify",
|
||||
href: `/environments/${environment.id}/workspace/unify`,
|
||||
icon: ShapesIcon,
|
||||
isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"),
|
||||
},
|
||||
{
|
||||
name: "Configure",
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/project"),
|
||||
@@ -185,7 +201,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -133,6 +133,11 @@ export const ProjectBreadcrumb = ({
|
||||
label: t("common.tags"),
|
||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||
},
|
||||
{
|
||||
id: "unify",
|
||||
label: "Unify Feedback",
|
||||
href: `/environments/${currentEnvironmentId}/workspace/unify`,
|
||||
},
|
||||
];
|
||||
|
||||
if (!currentProject) {
|
||||
|
||||
@@ -11,6 +11,12 @@ const EnvLayout = async (props: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
const { environmentId } = params;
|
||||
|
||||
if (environmentId === "undefined") {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const { children } = props;
|
||||
|
||||
// Check session first (required for userId)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
environmentId: string;
|
||||
activeId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const UnifyConfigNavigation = ({
|
||||
environmentId,
|
||||
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` },
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ControlsSection } from "./ControlsSection";
|
||||
@@ -0,0 +1,10 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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`);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
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 { TUnifySurvey, TUnifySurveyElement } from "./components/types";
|
||||
|
||||
// Helper to extract elements from a survey (from blocks or legacy questions)
|
||||
function extractElementsFromSurvey(survey: TSurvey): TUnifySurveyElement[] {
|
||||
const elements: TUnifySurveyElement[] = [];
|
||||
|
||||
// Try to get elements from blocks first (new structure)
|
||||
if (survey.blocks && survey.blocks.length > 0) {
|
||||
for (const block of survey.blocks) {
|
||||
if (block.elements) {
|
||||
for (const element of block.elements) {
|
||||
// Skip non-question elements (like CTA)
|
||||
if (element.type === TSurveyElementTypeEnum.CTA) continue;
|
||||
|
||||
elements.push({
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
headline: getElementHeadline(element),
|
||||
required: element.required ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy questions if no blocks
|
||||
if (elements.length === 0 && survey.questions && Array.isArray(survey.questions)) {
|
||||
for (const question of survey.questions as Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
headline?: string | { default?: string };
|
||||
required?: boolean;
|
||||
}>) {
|
||||
elements.push({
|
||||
id: question.id,
|
||||
type: question.type,
|
||||
headline: getQuestionHeadline(question),
|
||||
required: question.required ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
// Helper to strip HTML tags from a string
|
||||
function stripHtmlTags(html: string): string {
|
||||
// Remove HTML tags and decode common entities
|
||||
return html
|
||||
.replace(/<[^>]*>/g, "") // Remove HTML tags
|
||||
.replace(/ /g, " ") // Replace non-breaking spaces
|
||||
.replace(/&/g, "&") // Replace ampersands
|
||||
.replace(/</g, "<") // Replace less than
|
||||
.replace(/>/g, ">") // Replace greater than
|
||||
.replace(/"/g, '"') // Replace quotes
|
||||
.replace(/'/g, "'") // Replace apostrophes
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Helper to get element headline (handles i18n structure)
|
||||
function getElementHeadline(element: { headline?: string | { default?: string } }): string {
|
||||
if (!element.headline) return "Untitled";
|
||||
let headline: string;
|
||||
if (typeof element.headline === "string") {
|
||||
headline = element.headline;
|
||||
} else if (typeof element.headline === "object" && element.headline.default) {
|
||||
headline = element.headline.default;
|
||||
} else {
|
||||
return "Untitled";
|
||||
}
|
||||
// Strip HTML tags if present
|
||||
return stripHtmlTags(headline);
|
||||
}
|
||||
|
||||
// Helper to get question headline (handles i18n structure)
|
||||
function getQuestionHeadline(question: { headline?: string | { default?: string } }): string {
|
||||
if (!question.headline) return "Untitled";
|
||||
let headline: string;
|
||||
if (typeof question.headline === "string") {
|
||||
headline = question.headline;
|
||||
} else if (typeof question.headline === "object" && question.headline.default) {
|
||||
headline = question.headline.default;
|
||||
} else {
|
||||
return "Untitled";
|
||||
}
|
||||
// Strip HTML tags if present
|
||||
return stripHtmlTags(headline);
|
||||
}
|
||||
|
||||
// Map survey status
|
||||
function mapSurveyStatus(status: string): TUnifySurvey["status"] {
|
||||
switch (status) {
|
||||
case "inProgress":
|
||||
return "active";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "draft":
|
||||
return "draft";
|
||||
case "completed":
|
||||
return "completed";
|
||||
default:
|
||||
return "draft";
|
||||
}
|
||||
}
|
||||
|
||||
// Transform TSurvey to TUnifySurvey for the UI
|
||||
function transformToUnifySurvey(survey: TSurvey, responseCount: number): TUnifySurvey {
|
||||
return {
|
||||
id: survey.id,
|
||||
name: survey.name,
|
||||
status: mapSurveyStatus(survey.status),
|
||||
responseCount,
|
||||
elements: extractElementsFromSurvey(survey),
|
||||
createdAt: survey.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Get surveys for environment action
|
||||
const ZGetSurveysForUnifyAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getSurveysForUnifyAction = authenticatedActionClient
|
||||
.schema(ZGetSurveysForUnifyAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TUnifySurvey[]> => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Get surveys from the database
|
||||
const surveys = await getSurveys(parsedInput.environmentId);
|
||||
|
||||
// Transform to TUnifySurvey format
|
||||
// Note: We don't have response counts readily available, so using 0 for now
|
||||
// In a production implementation, we'd fetch response counts separately
|
||||
return surveys.map((survey) => transformToUnifySurvey(survey, 0));
|
||||
});
|
||||
@@ -0,0 +1,617 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, CopyIcon, PlusIcon, SparklesIcon, WebhookIcon } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { CsvSourceUI } from "./csv-source-ui";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
import { SourceTypeSelector } from "./source-type-selector";
|
||||
import {
|
||||
AI_SUGGESTED_MAPPINGS,
|
||||
EMAIL_SOURCE_FIELDS,
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
SAMPLE_WEBHOOK_PAYLOAD,
|
||||
TCreateSourceStep,
|
||||
TFieldMapping,
|
||||
TSourceConnection,
|
||||
TSourceField,
|
||||
TSourceType,
|
||||
TUnifySurvey,
|
||||
parseCSVColumnsToFields,
|
||||
parsePayloadToFields,
|
||||
} from "./types";
|
||||
|
||||
// Polling interval in milliseconds (3 seconds)
|
||||
const WEBHOOK_POLL_INTERVAL = 3000;
|
||||
|
||||
// Sample webhook payload for cURL example
|
||||
const SAMPLE_CURL_PAYLOAD = {
|
||||
timestamp: new Date().toISOString(),
|
||||
source_type: "webhook",
|
||||
field_id: "satisfaction_score",
|
||||
field_type: "rating",
|
||||
value_number: 4,
|
||||
user_id: "user_123",
|
||||
metadata: {
|
||||
source: "api",
|
||||
},
|
||||
};
|
||||
|
||||
interface CreateSourceModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateSource: (
|
||||
source: TSourceConnection,
|
||||
selectedSurveyId?: string,
|
||||
selectedElementIds?: string[]
|
||||
) => void;
|
||||
surveys: TUnifySurvey[];
|
||||
}
|
||||
|
||||
function getDefaultSourceName(type: TSourceType): string {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "Formbricks Survey Connection";
|
||||
case "webhook":
|
||||
return "Webhook Connection";
|
||||
case "email":
|
||||
return "Email Connection";
|
||||
case "csv":
|
||||
return "CSV Import";
|
||||
case "slack":
|
||||
return "Slack Connection";
|
||||
default:
|
||||
return "New Source";
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys }: CreateSourceModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<TCreateSourceStep>("selectType");
|
||||
const [selectedType, setSelectedType] = useState<TSourceType | null>(null);
|
||||
const [sourceName, setSourceName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
|
||||
|
||||
// Formbricks-specific state
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
|
||||
|
||||
// Webhook listener state
|
||||
const [webhookSessionId, setWebhookSessionId] = useState<string | null>(null);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [webhookReceived, setWebhookReceived] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Generate webhook URL
|
||||
const webhookUrl = webhookSessionId
|
||||
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/unify/webhook-listener/${webhookSessionId}`
|
||||
: "";
|
||||
|
||||
// Poll for webhook payload
|
||||
const pollForWebhook = useCallback(async () => {
|
||||
if (!webhookSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/unify/webhook-listener/${webhookSessionId}`);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
if (data.payload) {
|
||||
// Parse the received payload into source fields
|
||||
const fields = parsePayloadToFields(data.payload);
|
||||
setSourceFields(fields);
|
||||
setWebhookReceived(true);
|
||||
setIsListening(false);
|
||||
toast.success("Webhook received! Fields loaded.");
|
||||
|
||||
// Stop polling
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 204 means no payload yet, keep polling
|
||||
} catch (error) {
|
||||
console.error("Error polling for webhook:", error);
|
||||
}
|
||||
}, [webhookSessionId]);
|
||||
|
||||
// Start/stop polling based on listening state
|
||||
useEffect(() => {
|
||||
if (isListening && webhookSessionId) {
|
||||
// Start polling
|
||||
pollingIntervalRef.current = setInterval(pollForWebhook, WEBHOOK_POLL_INTERVAL);
|
||||
// Also poll immediately
|
||||
pollForWebhook();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup polling on unmount or when listening stops
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isListening, webhookSessionId, pollForWebhook]);
|
||||
|
||||
// Generate session ID when webhook type is selected and modal opens
|
||||
useEffect(() => {
|
||||
if (open && selectedType === "webhook" && currentStep === "mapping" && !webhookSessionId) {
|
||||
setWebhookSessionId(nanoid(21));
|
||||
setIsListening(true);
|
||||
}
|
||||
}, [open, selectedType, currentStep, webhookSessionId]);
|
||||
|
||||
// Copy cURL command to clipboard
|
||||
const handleCopyWebhookUrl = async () => {
|
||||
if (!webhookUrl) return;
|
||||
const curlCommand = `curl -X POST \\
|
||||
"${webhookUrl}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(SAMPLE_CURL_PAYLOAD, null, 2)}'`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(curlCommand);
|
||||
setCopied(true);
|
||||
toast.success("cURL command copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentStep("selectType");
|
||||
setSelectedType(null);
|
||||
setSourceName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setDeriveFromAttachments(false);
|
||||
setSelectedSurveyId(null);
|
||||
setSelectedElementIds([]);
|
||||
// Reset webhook state
|
||||
setWebhookSessionId(null);
|
||||
setIsListening(false);
|
||||
setWebhookReceived(false);
|
||||
setCopied(false);
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStep === "selectType" && selectedType && selectedType !== "slack") {
|
||||
if (selectedType === "formbricks") {
|
||||
// For Formbricks, use the survey name if selected
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
setSourceName(
|
||||
selectedSurvey ? `${selectedSurvey.name} Connection` : getDefaultSourceName(selectedType)
|
||||
);
|
||||
} else {
|
||||
setSourceName(getDefaultSourceName(selectedType));
|
||||
}
|
||||
setCurrentStep("mapping");
|
||||
}
|
||||
};
|
||||
|
||||
// Formbricks handlers
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
|
||||
const handleElementToggle = (elementId: string) => {
|
||||
setSelectedElementIds((prev) =>
|
||||
prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllElements = (surveyId: string) => {
|
||||
const survey = surveys.find((s) => s.id === surveyId);
|
||||
if (survey) {
|
||||
setSelectedElementIds(survey.elements.map((e) => e.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllElements = () => {
|
||||
setSelectedElementIds([]);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === "mapping") {
|
||||
setCurrentStep("selectType");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSource = () => {
|
||||
if (!selectedType || !sourceName.trim()) return;
|
||||
|
||||
// Check if all required fields are mapped (for non-Formbricks connectors)
|
||||
if (selectedType !== "formbricks") {
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const allRequiredMapped = requiredFields.every((field) =>
|
||||
mappings.some((m) => m.targetFieldId === field.id)
|
||||
);
|
||||
|
||||
if (!allRequiredMapped) {
|
||||
// For now, we'll allow creating without all required fields for POC
|
||||
console.warn("Not all required fields are mapped");
|
||||
}
|
||||
}
|
||||
|
||||
const newSource: TSourceConnection = {
|
||||
id: crypto.randomUUID(),
|
||||
name: sourceName.trim(),
|
||||
type: selectedType,
|
||||
mappings,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Pass the Formbricks-specific data if applicable
|
||||
onCreateSource(
|
||||
newSource,
|
||||
selectedType === "formbricks" ? (selectedSurveyId ?? undefined) : undefined,
|
||||
selectedType === "formbricks" ? selectedElementIds : undefined
|
||||
);
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const allRequiredMapped = requiredFields.every((field) =>
|
||||
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
|
||||
);
|
||||
|
||||
// Formbricks validation - need survey and at least one element selected
|
||||
const isFormbricksValid =
|
||||
selectedType === "formbricks" && selectedSurveyId && selectedElementIds.length > 0;
|
||||
|
||||
// CSV validation - need sourceFields loaded (CSV uploaded or sample loaded)
|
||||
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (!selectedType) return;
|
||||
let fields: TSourceField[];
|
||||
if (selectedType === "webhook") {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
} else if (selectedType === "email") {
|
||||
fields = EMAIL_SOURCE_FIELDS;
|
||||
} else if (selectedType === "csv") {
|
||||
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
} else {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
}
|
||||
setSourceFields(fields);
|
||||
};
|
||||
|
||||
const handleSuggestMapping = () => {
|
||||
if (!selectedType) return;
|
||||
const suggestions = AI_SUGGESTED_MAPPINGS[selectedType];
|
||||
if (!suggestions) return;
|
||||
|
||||
const newMappings: TFieldMapping[] = [];
|
||||
|
||||
// Add field mappings from source fields
|
||||
for (const sourceField of sourceFields) {
|
||||
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
|
||||
if (suggestedTarget) {
|
||||
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
|
||||
if (targetExists) {
|
||||
newMappings.push({
|
||||
sourceFieldId: sourceField.id,
|
||||
targetFieldId: suggestedTarget,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add static value mappings
|
||||
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
|
||||
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
|
||||
if (targetExists) {
|
||||
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
|
||||
newMappings.push({
|
||||
targetFieldId,
|
||||
staticValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMappings(newMappings);
|
||||
};
|
||||
|
||||
const getLoadButtonLabel = () => {
|
||||
switch (selectedType) {
|
||||
case "webhook":
|
||||
return "Simulate webhook";
|
||||
case "email":
|
||||
return "Load email fields";
|
||||
case "csv":
|
||||
return "Load sample CSV";
|
||||
default:
|
||||
return "Load sample";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
Add source
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{currentStep === "selectType"
|
||||
? "Add Feedback Source"
|
||||
: selectedType === "formbricks"
|
||||
? "Select Survey & Questions"
|
||||
: selectedType === "csv"
|
||||
? "Import CSV Data"
|
||||
: "Configure Mapping"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentStep === "selectType"
|
||||
? "Select the type of feedback source you want to connect."
|
||||
: selectedType === "formbricks"
|
||||
? "Choose which survey questions should create FeedbackRecords."
|
||||
: selectedType === "csv"
|
||||
? "Upload a CSV file or set up automated S3 imports."
|
||||
: "Map source fields to Hub Feedback Record fields."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{currentStep === "selectType" ? (
|
||||
<SourceTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
) : selectedType === "formbricks" ? (
|
||||
/* Formbricks Survey Selector UI */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceName">Source Name</Label>
|
||||
<Input
|
||||
id="sourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<FormbricksSurveySelector
|
||||
surveys={surveys}
|
||||
selectedSurveyId={selectedSurveyId}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSurveySelect={handleSurveySelect}
|
||||
onElementToggle={handleElementToggle}
|
||||
onSelectAllElements={handleSelectAllElements}
|
||||
onDeselectAllElements={handleDeselectAllElements}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedType === "csv" ? (
|
||||
/* CSV Upload & S3 Integration UI */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceName">Source Name</Label>
|
||||
<Input
|
||||
id="sourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<CsvSourceUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
onSourceFieldsChange={setSourceFields}
|
||||
onLoadSampleCSV={handleLoadSourceFields}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Other source types (webhook, email) - Mapping UI */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceName">Source Name</Label>
|
||||
<Input
|
||||
id="sourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Webhook Listener UI */}
|
||||
{selectedType === "webhook" && !webhookReceived && (
|
||||
<div className="space-y-6">
|
||||
{/* Centered waiting indicator */}
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-slate-200 bg-slate-50 py-12">
|
||||
<span className="relative mb-4 flex h-16 w-16">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-slate-300 opacity-75"></span>
|
||||
<span className="relative inline-flex h-16 w-16 items-center justify-center rounded-full bg-slate-200">
|
||||
<WebhookIcon className="h-8 w-8 text-slate-600" />
|
||||
</span>
|
||||
</span>
|
||||
<p className="text-lg font-medium text-slate-700">Waiting for webhook...</p>
|
||||
<p className="mt-1 text-sm text-slate-500">Send a request to the URL below</p>
|
||||
</div>
|
||||
|
||||
{/* cURL example at bottom */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-slate-700">Test with cURL</Label>
|
||||
<div className="relative">
|
||||
<pre className="overflow-auto rounded-lg border border-slate-300 bg-slate-900 p-3 text-xs text-slate-100">
|
||||
<code>{`curl -X POST "${webhookUrl || "..."}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(SAMPLE_CURL_PAYLOAD, null, 2)}'`}</code>
|
||||
</pre>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopyWebhookUrl}
|
||||
disabled={!webhookUrl}
|
||||
className="absolute right-2 top-2">
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="mr-1 h-3 w-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhook received - show success + mapping UI */}
|
||||
{selectedType === "webhook" && webhookReceived && (
|
||||
<div className="space-y-4">
|
||||
{/* Success indicator */}
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-green-200 bg-green-50 py-6">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-500">
|
||||
<CheckIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-green-700">Webhook received!</p>
|
||||
<p className="mt-1 text-sm text-green-600">
|
||||
{sourceFields.length} fields detected. Map them below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI suggest mapping button */}
|
||||
{sourceFields.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={selectedType}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-webhook types */}
|
||||
{selectedType !== "webhook" && (
|
||||
<>
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
|
||||
{getLoadButtonLabel()}
|
||||
</Button>
|
||||
{sourceFields.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSuggestMapping}
|
||||
className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={selectedType!}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{currentStep === "mapping" && (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === "selectType" ? (
|
||||
<Button onClick={handleNextStep} disabled={!selectedType || selectedType === "slack"}>
|
||||
{selectedType === "formbricks"
|
||||
? "Select questions"
|
||||
: selectedType === "csv"
|
||||
? "Configure import"
|
||||
: "Create mapping"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleCreateSource}
|
||||
disabled={
|
||||
!sourceName.trim() ||
|
||||
(selectedType === "formbricks"
|
||||
? !isFormbricksValid
|
||||
: selectedType === "csv"
|
||||
? !isCsvValid
|
||||
: !allRequiredMapped)
|
||||
}>
|
||||
Setup connection
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
CloudIcon,
|
||||
CopyIcon,
|
||||
FolderIcon,
|
||||
RefreshCwIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
import { TFieldMapping, TSourceField } from "./types";
|
||||
|
||||
interface CsvSourceUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
onSourceFieldsChange: (fields: TSourceField[]) => void;
|
||||
onLoadSampleCSV: () => void;
|
||||
}
|
||||
|
||||
export function CsvSourceUI({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
onSourceFieldsChange,
|
||||
onLoadSampleCSV,
|
||||
}: CsvSourceUIProps) {
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
|
||||
const [showMapping, setShowMapping] = useState(false);
|
||||
const [s3AutoSync, setS3AutoSync] = useState(false);
|
||||
const [s3Copied, setS3Copied] = useState(false);
|
||||
|
||||
// Mock S3 bucket details
|
||||
const s3BucketName = "formbricks-feedback-imports";
|
||||
const s3Path = `s3://${s3BucketName}/feedback/incoming/`;
|
||||
|
||||
const handleCopyS3Path = () => {
|
||||
navigator.clipboard.writeText(s3Path);
|
||||
setS3Copied(true);
|
||||
setTimeout(() => setS3Copied(false), 2000);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) {
|
||||
processCSVFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const processCSVFile = (file: File) => {
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvFile(file);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const csv = e.target?.result as string;
|
||||
const lines = csv.split("\n").slice(0, 6); // Preview first 5 rows + header
|
||||
const preview = lines.map((line) => line.split(",").map((cell) => cell.trim()));
|
||||
setCsvPreview(preview);
|
||||
|
||||
// Extract columns and create source fields
|
||||
if (preview.length > 0) {
|
||||
const headers = preview[0];
|
||||
const fields: TSourceField[] = headers.map((header) => ({
|
||||
id: header,
|
||||
name: header,
|
||||
type: "string",
|
||||
sampleValue: preview[1]?.[headers.indexOf(header)] || "",
|
||||
}));
|
||||
onSourceFieldsChange(fields);
|
||||
setShowMapping(true);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
processCSVFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSample = () => {
|
||||
onLoadSampleCSV();
|
||||
setShowMapping(true);
|
||||
};
|
||||
|
||||
// If mapping is shown, show the mapping UI
|
||||
if (showMapping && sourceFields.length > 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* File info bar */}
|
||||
{csvFile && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderIcon className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-800">{csvFile.name}</span>
|
||||
<Badge text={`${csvPreview.length - 1} rows`} type="success" size="tiny" />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCsvFile(null);
|
||||
setCsvPreview([]);
|
||||
setShowMapping(false);
|
||||
onSourceFieldsChange([]);
|
||||
}}>
|
||||
Change file
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSV Preview Table */}
|
||||
{csvPreview.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{csvPreview[0]?.map((header, i) => (
|
||||
<th key={i} className="px-3 py-2 text-left font-medium text-slate-700">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvPreview.slice(1, 4).map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t border-slate-100">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="px-3 py-2 text-slate-600">
|
||||
{cell || <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{csvPreview.length > 4 && (
|
||||
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
|
||||
Showing 3 of {csvPreview.length - 1} rows
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mapping UI */}
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={onMappingsChange}
|
||||
sourceType="csv"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Upload and S3 setup UI
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Manual Upload Section */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Upload CSV File</h4>
|
||||
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
|
||||
<label
|
||||
htmlFor="csv-file-upload"
|
||||
className="flex cursor-pointer flex-col items-center justify-center"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}>
|
||||
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">CSV files only</p>
|
||||
<input
|
||||
type="file"
|
||||
id="csv-file-upload"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
|
||||
Load sample CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
<span className="text-xs font-medium uppercase text-slate-400">or</span>
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
|
||||
{/* S3 Integration Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudIcon className="h-5 w-5 text-slate-500" />
|
||||
<h4 className="text-sm font-medium text-slate-700">S3 Bucket Integration</h4>
|
||||
<Badge text="Automated" type="gray" size="tiny" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<p className="mb-4 text-sm text-slate-600">
|
||||
Drop CSV files into your S3 bucket to automatically import feedback. Files are processed every 15
|
||||
minutes.
|
||||
</p>
|
||||
|
||||
{/* S3 Path Display */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Drop zone path</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-slate-100 px-3 py-2 font-mono text-sm text-slate-700">
|
||||
{s3Path}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyS3Path}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{s3Copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* S3 Settings */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">AWS Region</Label>
|
||||
<Select defaultValue="eu-central-1">
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
|
||||
<SelectItem value="us-west-2">US West (Oregon)</SelectItem>
|
||||
<SelectItem value="eu-central-1">EU (Frankfurt)</SelectItem>
|
||||
<SelectItem value="eu-west-1">EU (Ireland)</SelectItem>
|
||||
<SelectItem value="ap-southeast-1">Asia Pacific (Singapore)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Processing interval</Label>
|
||||
<Select defaultValue="15">
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">Every 5 minutes</SelectItem>
|
||||
<SelectItem value="15">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="30">Every 30 minutes</SelectItem>
|
||||
<SelectItem value="60">Every hour</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-sync toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-slate-900">Enable auto-sync</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
Automatically process new files dropped in the bucket
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={s3AutoSync} onCheckedChange={setS3AutoSync} />
|
||||
</div>
|
||||
|
||||
{/* IAM Instructions */}
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<SettingsIcon className="mt-0.5 h-4 w-4 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800">IAM Configuration Required</p>
|
||||
<p className="mt-1 text-xs text-amber-700">
|
||||
Add the Formbricks IAM role to your S3 bucket policy to enable access.{" "}
|
||||
<button type="button" className="font-medium underline hover:no-underline">
|
||||
View setup guide →
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<RefreshCwIcon className="h-4 w-4" />
|
||||
Test connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FileSpreadsheetIcon,
|
||||
GlobeIcon,
|
||||
MailIcon,
|
||||
MessageSquareIcon,
|
||||
SparklesIcon,
|
||||
WebhookIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
import {
|
||||
AI_SUGGESTED_MAPPINGS,
|
||||
EMAIL_SOURCE_FIELDS,
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
SAMPLE_WEBHOOK_PAYLOAD,
|
||||
TFieldMapping,
|
||||
TSourceConnection,
|
||||
TSourceField,
|
||||
TSourceType,
|
||||
TUnifySurvey,
|
||||
parseCSVColumnsToFields,
|
||||
parsePayloadToFields,
|
||||
} from "./types";
|
||||
|
||||
// Polling interval in milliseconds (3 seconds)
|
||||
const WEBHOOK_POLL_INTERVAL = 3000;
|
||||
|
||||
// Sample webhook payload for cURL example
|
||||
const SAMPLE_CURL_PAYLOAD = {
|
||||
timestamp: new Date().toISOString(),
|
||||
source_type: "webhook",
|
||||
field_id: "satisfaction_score",
|
||||
field_type: "rating",
|
||||
value_number: 4,
|
||||
user_id: "user_123",
|
||||
metadata: {
|
||||
source: "api",
|
||||
},
|
||||
};
|
||||
|
||||
interface EditSourceModalProps {
|
||||
source: TSourceConnection | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdateSource: (
|
||||
source: TSourceConnection,
|
||||
selectedSurveyId?: string,
|
||||
selectedElementIds?: string[]
|
||||
) => void;
|
||||
onDeleteSource: (sourceId: string) => void;
|
||||
surveys: TUnifySurvey[];
|
||||
// For Formbricks connectors - the currently selected survey/elements
|
||||
initialSurveyId?: string | null;
|
||||
initialElementIds?: string[];
|
||||
}
|
||||
|
||||
function getSourceIcon(type: TSourceType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "webhook":
|
||||
return <WebhookIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "email":
|
||||
return <MailIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "slack":
|
||||
return <MessageSquareIcon className="h-5 w-5 text-slate-500" />;
|
||||
default:
|
||||
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceTypeLabel(type: TSourceType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "Formbricks Surveys";
|
||||
case "webhook":
|
||||
return "Webhook";
|
||||
case "email":
|
||||
return "Email";
|
||||
case "csv":
|
||||
return "CSV Import";
|
||||
case "slack":
|
||||
return "Slack Message";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialSourceFields(type: TSourceType): TSourceField[] {
|
||||
switch (type) {
|
||||
case "webhook":
|
||||
return parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
case "email":
|
||||
return EMAIL_SOURCE_FIELDS;
|
||||
case "csv":
|
||||
return parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function EditSourceModal({
|
||||
source,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdateSource,
|
||||
onDeleteSource,
|
||||
surveys,
|
||||
initialSurveyId,
|
||||
initialElementIds = [],
|
||||
}: EditSourceModalProps) {
|
||||
const [sourceName, setSourceName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
|
||||
|
||||
// Formbricks-specific state
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
|
||||
|
||||
// Webhook listener state
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [webhookReceived, setWebhookReceived] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Permanent webhook URL using connector ID
|
||||
const webhookUrl = source
|
||||
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/unify/webhook/${source.id}`
|
||||
: "";
|
||||
|
||||
// Poll for webhook payload using connector ID
|
||||
const pollForWebhook = useCallback(async () => {
|
||||
if (!source?.id) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/unify/webhook/${source.id}`);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
if (data.payload) {
|
||||
// Parse the received payload into source fields
|
||||
const fields = parsePayloadToFields(data.payload);
|
||||
setSourceFields(fields);
|
||||
setWebhookReceived(true);
|
||||
setIsListening(false);
|
||||
toast.success("Webhook received! Fields loaded.");
|
||||
|
||||
// Stop polling
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 204 means no payload yet, keep polling
|
||||
} catch (error) {
|
||||
console.error("Error polling for webhook:", error);
|
||||
}
|
||||
}, [source?.id]);
|
||||
|
||||
// Start/stop polling based on listening state
|
||||
useEffect(() => {
|
||||
if (isListening && source?.id) {
|
||||
// Start polling
|
||||
pollingIntervalRef.current = setInterval(pollForWebhook, WEBHOOK_POLL_INTERVAL);
|
||||
// Also poll immediately
|
||||
pollForWebhook();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup polling on unmount or when listening stops
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isListening, source?.id, pollForWebhook]);
|
||||
|
||||
// Copy webhook URL to clipboard
|
||||
const handleCopyWebhookUrl = async () => {
|
||||
if (!webhookUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
setCopied(true);
|
||||
toast.success("Webhook URL copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (source) {
|
||||
setSourceName(source.name);
|
||||
setMappings(source.mappings);
|
||||
setDeriveFromAttachments(false);
|
||||
|
||||
// For Formbricks connectors, set the initial survey/element selection
|
||||
if (source.type === "formbricks") {
|
||||
setSelectedSurveyId(initialSurveyId ?? null);
|
||||
setSelectedElementIds(initialElementIds);
|
||||
setSourceFields(getInitialSourceFields(source.type));
|
||||
} else if (source.type === "webhook") {
|
||||
// Webhook: if we already have mappings, show them; otherwise show listening state
|
||||
if (source.mappings.length > 0) {
|
||||
// Build source fields from existing mapping source IDs so the mapping UI can display them
|
||||
const sourceFieldIds = new Set<string>();
|
||||
for (const m of source.mappings) {
|
||||
if (m.sourceFieldId) sourceFieldIds.add(m.sourceFieldId);
|
||||
}
|
||||
const fieldsFromMappings = Array.from(sourceFieldIds).map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
type: "string",
|
||||
sampleValue: "",
|
||||
}));
|
||||
setSourceFields(fieldsFromMappings);
|
||||
setWebhookReceived(true);
|
||||
setIsListening(false);
|
||||
} else {
|
||||
setSourceFields([]);
|
||||
setIsListening(true);
|
||||
setWebhookReceived(false);
|
||||
}
|
||||
} else {
|
||||
setSourceFields(getInitialSourceFields(source.type));
|
||||
}
|
||||
}
|
||||
}, [source, initialSurveyId, initialElementIds]);
|
||||
|
||||
const resetForm = () => {
|
||||
setSourceName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setShowDeleteConfirm(false);
|
||||
setDeriveFromAttachments(false);
|
||||
setSelectedSurveyId(null);
|
||||
setSelectedElementIds([]);
|
||||
// Reset webhook state
|
||||
setIsListening(false);
|
||||
setWebhookReceived(false);
|
||||
setCopied(false);
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
// Formbricks handlers
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
|
||||
const handleElementToggle = (elementId: string) => {
|
||||
setSelectedElementIds((prev) =>
|
||||
prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllElements = (surveyId: string) => {
|
||||
const survey = surveys.find((s) => s.id === surveyId);
|
||||
if (survey) {
|
||||
setSelectedElementIds(survey.elements.map((e) => e.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllElements = () => {
|
||||
setSelectedElementIds([]);
|
||||
};
|
||||
|
||||
const handleUpdateSource = () => {
|
||||
if (!source || !sourceName.trim()) return;
|
||||
|
||||
const updatedSource: TSourceConnection = {
|
||||
...source,
|
||||
name: sourceName.trim(),
|
||||
mappings,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// For Formbricks, pass the survey/element selection
|
||||
if (source.type === "formbricks") {
|
||||
onUpdateSource(updatedSource, selectedSurveyId ?? undefined, selectedElementIds);
|
||||
} else {
|
||||
onUpdateSource(updatedSource);
|
||||
}
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteSource = () => {
|
||||
if (!source) return;
|
||||
onDeleteSource(source.id);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (!source) return;
|
||||
let fields: TSourceField[];
|
||||
if (source.type === "webhook") {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
} else if (source.type === "email") {
|
||||
fields = EMAIL_SOURCE_FIELDS;
|
||||
} else if (source.type === "csv") {
|
||||
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
} else {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
}
|
||||
setSourceFields(fields);
|
||||
};
|
||||
|
||||
const handleSuggestMapping = () => {
|
||||
if (!source) return;
|
||||
const suggestions = AI_SUGGESTED_MAPPINGS[source.type];
|
||||
if (!suggestions) return;
|
||||
|
||||
const newMappings: TFieldMapping[] = [];
|
||||
|
||||
for (const sourceField of sourceFields) {
|
||||
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
|
||||
if (suggestedTarget) {
|
||||
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
|
||||
if (targetExists) {
|
||||
newMappings.push({
|
||||
sourceFieldId: sourceField.id,
|
||||
targetFieldId: suggestedTarget,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
|
||||
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
|
||||
if (targetExists) {
|
||||
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
|
||||
newMappings.push({
|
||||
targetFieldId,
|
||||
staticValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMappings(newMappings);
|
||||
};
|
||||
|
||||
const getLoadButtonLabel = () => {
|
||||
switch (source?.type) {
|
||||
case "webhook":
|
||||
return "Simulate webhook";
|
||||
case "email":
|
||||
return "Load email fields";
|
||||
case "csv":
|
||||
return "Load sample CSV";
|
||||
default:
|
||||
return "Load sample";
|
||||
}
|
||||
};
|
||||
|
||||
if (!source) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Source Connection</DialogTitle>
|
||||
<DialogDescription>Update the mapping configuration for this source.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Source Type Display */}
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
{getSourceIcon(source.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{getSourceTypeLabel(source.type)}</p>
|
||||
<p className="text-xs text-slate-500">Source type cannot be changed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editSourceName">Source Name</Label>
|
||||
<Input
|
||||
id="editSourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{source.type === "formbricks" ? (
|
||||
/* Formbricks Survey Selector UI */
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<FormbricksSurveySelector
|
||||
surveys={surveys}
|
||||
selectedSurveyId={selectedSurveyId}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSurveySelect={handleSurveySelect}
|
||||
onElementToggle={handleElementToggle}
|
||||
onSelectAllElements={handleSelectAllElements}
|
||||
onDeselectAllElements={handleDeselectAllElements}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Other source types - Mapping UI */
|
||||
<>
|
||||
{/* Webhook Listener UI - Waiting state */}
|
||||
{source.type === "webhook" && !webhookReceived && (
|
||||
<div className="space-y-4">
|
||||
{/* Permanent Webhook URL */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||
<WebhookIcon className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-blue-900">Your Webhook URL</p>
|
||||
<p className="mt-0.5 text-xs text-blue-700">
|
||||
This is your permanent webhook endpoint. Use it in your integrations.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-white px-2 py-1 text-xs text-blue-800">
|
||||
{webhookUrl || "Loading..."}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyWebhookUrl}
|
||||
disabled={!webhookUrl}
|
||||
className="shrink-0 border-blue-300 text-blue-700 hover:bg-blue-100">
|
||||
{copied ? <CheckIcon className="h-3 w-3" /> : <CopyIcon className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Centered waiting indicator */}
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-slate-200 bg-slate-50 py-8">
|
||||
<span className="relative mb-3 flex h-12 w-12">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-slate-300 opacity-75"></span>
|
||||
<span className="relative inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-200">
|
||||
<WebhookIcon className="h-6 w-6 text-slate-600" />
|
||||
</span>
|
||||
</span>
|
||||
<p className="text-sm font-medium text-slate-700">Listening for test payload...</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Send a request to update field mappings</p>
|
||||
</div>
|
||||
|
||||
{/* cURL example */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-slate-700">Test with cURL</Label>
|
||||
<div className="relative">
|
||||
<pre className="overflow-auto rounded-lg border border-slate-300 bg-slate-900 p-3 text-xs text-slate-100">
|
||||
<code>{`curl -X POST "${webhookUrl || "..."}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(SAMPLE_CURL_PAYLOAD, null, 2)}'`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhook configured - show mapping UI */}
|
||||
{source.type === "webhook" && webhookReceived && (
|
||||
<div className="space-y-4">
|
||||
{/* Webhook URL + copy (when already configured) */}
|
||||
<div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<span className="text-xs font-medium text-slate-600">Webhook URL:</span>
|
||||
<code className="min-w-0 flex-1 truncate text-xs text-slate-700">
|
||||
{webhookUrl || "..."}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyWebhookUrl}
|
||||
disabled={!webhookUrl}
|
||||
className="h-7 shrink-0 px-2">
|
||||
{copied ? <CheckIcon className="h-3 w-3" /> : <CopyIcon className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* AI suggest mapping button */}
|
||||
{sourceFields.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={source.type}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-webhook types */}
|
||||
{source.type !== "webhook" && (
|
||||
<>
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
|
||||
{getLoadButtonLabel()}
|
||||
</Button>
|
||||
{sourceFields.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={source.type}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-between">
|
||||
<div>
|
||||
{showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600">Are you sure?</span>
|
||||
<Button variant="destructive" size="sm" onClick={handleDeleteSource}>
|
||||
Yes, delete
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setShowDeleteConfirm(true)}>
|
||||
Delete source
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleUpdateSource}
|
||||
disabled={
|
||||
!sourceName.trim() ||
|
||||
(source.type === "formbricks" && (!selectedSurveyId || selectedElementIds.length === 0))
|
||||
}>
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CircleIcon,
|
||||
FileTextIcon,
|
||||
MessageSquareTextIcon,
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { TUnifySurvey, getElementTypeLabel } from "./types";
|
||||
|
||||
interface FormbricksSurveySelectorProps {
|
||||
surveys: TUnifySurvey[];
|
||||
selectedSurveyId: string | null;
|
||||
selectedElementIds: string[];
|
||||
onSurveySelect: (surveyId: string | null) => void;
|
||||
onElementToggle: (elementId: string) => void;
|
||||
onSelectAllElements: (surveyId: string) => void;
|
||||
onDeselectAllElements: () => void;
|
||||
}
|
||||
|
||||
function getElementIcon(type: string) {
|
||||
switch (type) {
|
||||
case "openText":
|
||||
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "rating":
|
||||
case "nps":
|
||||
return <StarIcon className="h-4 w-4 text-amber-500" />;
|
||||
default:
|
||||
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: TUnifySurvey["status"]) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge text="Active" type="success" size="tiny" />;
|
||||
case "paused":
|
||||
return <Badge text="Paused" type="warning" size="tiny" />;
|
||||
case "draft":
|
||||
return <Badge text="Draft" type="gray" size="tiny" />;
|
||||
case "completed":
|
||||
return <Badge text="Completed" type="gray" size="tiny" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function FormbricksSurveySelector({
|
||||
surveys,
|
||||
selectedSurveyId,
|
||||
selectedElementIds,
|
||||
onSurveySelect,
|
||||
onElementToggle,
|
||||
onSelectAllElements,
|
||||
onDeselectAllElements,
|
||||
}: FormbricksSurveySelectorProps) {
|
||||
const [expandedSurveyId, setExpandedSurveyId] = useState<string | null>(null);
|
||||
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
|
||||
const handleSurveyClick = (survey: TUnifySurvey) => {
|
||||
if (selectedSurveyId === survey.id) {
|
||||
// Toggle expand/collapse if already selected
|
||||
setExpandedSurveyId(expandedSurveyId === survey.id ? null : survey.id);
|
||||
} else {
|
||||
// Select the survey and expand it
|
||||
onSurveySelect(survey.id);
|
||||
onDeselectAllElements();
|
||||
setExpandedSurveyId(survey.id);
|
||||
}
|
||||
};
|
||||
|
||||
const allElementsSelected = selectedSurvey && selectedElementIds.length === selectedSurvey.elements.length;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Left: Survey List */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Select Survey</h4>
|
||||
<div className="space-y-2">
|
||||
{surveys.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">No surveys found in this environment</p>
|
||||
</div>
|
||||
) : (
|
||||
surveys.map((survey) => {
|
||||
const isSelected = selectedSurveyId === survey.id;
|
||||
const isExpanded = expandedSurveyId === survey.id;
|
||||
|
||||
return (
|
||||
<div key={survey.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSurveyClick(survey)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? "border-brand-dark bg-slate-50"
|
||||
: "border-slate-200 bg-white hover:border-slate-300"
|
||||
}`}>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-100">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-600" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4 text-slate-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-900">{survey.name}</span>
|
||||
{getStatusBadge(survey.status)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{survey.elements.length} elements
|
||||
{survey.responseCount > 0 && ` · ${survey.responseCount.toLocaleString()} responses`}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <CheckCircle2Icon className="text-brand-dark h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Element Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">Select Elements</h4>
|
||||
{selectedSurvey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
allElementsSelected ? onDeselectAllElements() : onSelectAllElements(selectedSurvey.id)
|
||||
}
|
||||
className="text-xs text-slate-500 hover:text-slate-700">
|
||||
{allElementsSelected ? "Deselect all" : "Select all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedSurvey ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">Select a survey to see its elements</p>
|
||||
</div>
|
||||
) : selectedSurvey.elements.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">This survey has no question elements</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedSurvey.elements.map((element) => {
|
||||
const isSelected = selectedElementIds.includes(element.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={element.id}
|
||||
type="button"
|
||||
onClick={() => onElementToggle(element.id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? "border-green-300 bg-green-50"
|
||||
: "border-slate-200 bg-white hover:border-slate-300"
|
||||
}`}>
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||
isSelected ? "bg-green-500 text-white" : "border border-slate-300 bg-white"
|
||||
}`}>
|
||||
{isSelected && <CheckIcon className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{getElementIcon(element.type)}</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-slate-900">{element.headline}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{getElementTypeLabel(element.type)}</span>
|
||||
{element.required && (
|
||||
<span className="text-xs text-red-500">
|
||||
<CircleIcon className="inline h-1.5 w-1.5 fill-current" /> Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedElementIds.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<strong>{selectedElementIds.length}</strong> element
|
||||
{selectedElementIds.length !== 1 ? "s" : ""} selected. Each response to these elements will
|
||||
create a FeedbackRecord in the Hub.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { CreateSourceModal } from "./create-source-modal";
|
||||
export { CsvSourceUI } from "./csv-source-ui";
|
||||
export { EditSourceModal } from "./edit-source-modal";
|
||||
export { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
export { MappingUI } from "./mapping-ui";
|
||||
export { SourcesSection } from "./sources-page-client";
|
||||
export { SourcesTable } from "./sources-table";
|
||||
export { SourcesTableDataRow } from "./sources-table-data-row";
|
||||
export { SourceTypeSelector } from "./source-type-selector";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { TFieldMapping, TSourceField, TTargetField } from "./types";
|
||||
|
||||
interface DraggableSourceFieldProps {
|
||||
field: TSourceField;
|
||||
isMapped: boolean;
|
||||
}
|
||||
|
||||
export function DraggableSourceField({ field, isMapped }: DraggableSourceFieldProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
isDragging
|
||||
? "border-brand-dark bg-slate-100 opacity-50"
|
||||
: isMapped
|
||||
? "border-green-300 bg-green-50 text-green-800"
|
||||
: "border-slate-200 bg-white hover:border-slate-300"
|
||||
}`}>
|
||||
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
|
||||
<div className="flex-1 truncate">
|
||||
<span className="font-medium">{field.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
|
||||
</div>
|
||||
{field.sampleValue && (
|
||||
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DroppableTargetFieldProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
export function DroppableTargetField({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
isOver,
|
||||
}: DroppableTargetFieldProps) {
|
||||
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const [isEditingStatic, setIsEditingStatic] = useState(false);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
const isActive = isOver || isOverCurrent;
|
||||
const hasMapping = mappedSourceField || mapping?.staticValue;
|
||||
|
||||
// Handle enum field type - show dropdown
|
||||
if (field.type === "enum" && field.enumValues) {
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
mapping?.staticValue ? "border-green-300 bg-green-50" : "border-dashed border-slate-300 bg-slate-50"
|
||||
}`}>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">(enum)</span>
|
||||
</div>
|
||||
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
|
||||
<SelectTrigger className="h-8 w-full bg-white">
|
||||
<SelectValue placeholder="Select a value..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.enumValues.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle string fields - allow drag & drop OR static value
|
||||
if (field.type === "string") {
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? "border-brand-dark bg-slate-100"
|
||||
: hasMapping
|
||||
? "border-green-300 bg-green-50"
|
||||
: "border-dashed border-slate-300 bg-slate-50"
|
||||
}`}>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
</div>
|
||||
|
||||
{/* Show mapped source field */}
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemoveMapping}
|
||||
className="ml-1 rounded p-0.5 hover:bg-green-100">
|
||||
<XIcon className="h-3 w-3 text-green-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show static value */}
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= "{mapping.staticValue}"
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemoveMapping}
|
||||
className="ml-1 rounded p-0.5 hover:bg-blue-100">
|
||||
<XIcon className="h-3 w-3 text-blue-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show input for entering static value when editing */}
|
||||
{isEditingStatic && !hasMapping && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
placeholder={
|
||||
field.exampleStaticValues ? `e.g., ${field.exampleStaticValues[0]}` : "Enter value..."
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
}
|
||||
setIsEditingStatic(false);
|
||||
}}
|
||||
className="rounded p-1 text-slate-500 hover:bg-slate-200">
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show example values as quick select OR drop zone */}
|
||||
{!hasMapping && !isEditingStatic && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">Drop field or</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingStatic(true)}
|
||||
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
set value
|
||||
</button>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.slice(0, 3).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to get display label for static values
|
||||
const getStaticValueLabel = (value: string) => {
|
||||
if (value === "$now") return "Feedback date";
|
||||
return value;
|
||||
};
|
||||
|
||||
// Default behavior for other field types (timestamp, float64, boolean, jsonb, etc.)
|
||||
const hasDefaultMapping = mappedSourceField || mapping?.staticValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? "border-brand-dark bg-slate-100"
|
||||
: hasDefaultMapping
|
||||
? "border-green-300 bg-green-50"
|
||||
: "border-dashed border-slate-300 bg-slate-50"
|
||||
}`}>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">({field.type})</span>
|
||||
</div>
|
||||
|
||||
{/* Show mapped source field */}
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-green-100">
|
||||
<XIcon className="h-3 w-3 text-green-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show static value */}
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= {getStaticValueLabel(mapping.staticValue)}
|
||||
</span>
|
||||
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-blue-100">
|
||||
<XIcon className="h-3 w-3 text-blue-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show drop zone with preset options */}
|
||||
{!hasDefaultMapping && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">Drop a field here</span>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{getStaticValueLabel(val)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
|
||||
import { CopyIcon, MailIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField, TSourceType } from "./types";
|
||||
|
||||
interface MappingUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
sourceType: TSourceType;
|
||||
deriveFromAttachments?: boolean;
|
||||
onDeriveFromAttachmentsChange?: (value: boolean) => void;
|
||||
emailInboxId?: string;
|
||||
}
|
||||
|
||||
export function MappingUI({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
sourceType,
|
||||
deriveFromAttachments = false,
|
||||
onDeriveFromAttachmentsChange,
|
||||
emailInboxId,
|
||||
}: MappingUIProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [emailCopied, setEmailCopied] = useState(false);
|
||||
|
||||
// Generate a stable random email ID if not provided
|
||||
const generatedEmailId = useMemo(() => {
|
||||
if (emailInboxId) return emailInboxId;
|
||||
return `fb-${Math.random().toString(36).substring(2, 8)}`;
|
||||
}, [emailInboxId]);
|
||||
|
||||
const inboxEmail = `${generatedEmailId}@inbox.formbricks.com`;
|
||||
|
||||
const handleCopyEmail = () => {
|
||||
navigator.clipboard.writeText(inboxEmail);
|
||||
setEmailCopied(true);
|
||||
setTimeout(() => setEmailCopied(false), 2000);
|
||||
};
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const sourceFieldId = active.id as string;
|
||||
const targetFieldId = over.id as string;
|
||||
|
||||
// Check if this target already has a mapping
|
||||
const existingMapping = mappings.find((m) => m.targetFieldId === targetFieldId);
|
||||
if (existingMapping) {
|
||||
// Remove the existing mapping first
|
||||
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||
} else {
|
||||
// Remove any existing mapping for this source field
|
||||
const newMappings = mappings.filter((m) => m.sourceFieldId !== sourceFieldId);
|
||||
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (targetFieldId: string) => {
|
||||
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
|
||||
};
|
||||
|
||||
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
|
||||
// Remove any existing mapping for this target field
|
||||
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||
// Add new static value mapping
|
||||
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
|
||||
};
|
||||
|
||||
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
|
||||
const getMappingForTarget = (targetFieldId: string) => {
|
||||
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
|
||||
};
|
||||
const getMappedSourceField = (targetFieldId: string) => {
|
||||
const mapping = getMappingForTarget(targetFieldId);
|
||||
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
|
||||
};
|
||||
const isSourceFieldMapped = (sourceFieldId: string) =>
|
||||
mappings.some((m) => m.sourceFieldId === sourceFieldId);
|
||||
|
||||
const activeField = activeId ? getSourceFieldById(activeId) : null;
|
||||
|
||||
const getSourceTypeLabel = () => {
|
||||
switch (sourceType) {
|
||||
case "webhook":
|
||||
return "Webhook Payload";
|
||||
case "email":
|
||||
return "Email Fields";
|
||||
case "csv":
|
||||
return "CSV Columns";
|
||||
default:
|
||||
return "Source Fields";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{/* Email inbox address display */}
|
||||
{sourceType === "email" && (
|
||||
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
<MailIcon className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-900">Your feedback inbox</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
Forward emails to this address to capture feedback automatically
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="rounded bg-white px-2 py-1 font-mono text-sm text-blue-700">
|
||||
{inboxEmail}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyEmail}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100">
|
||||
<CopyIcon className="h-3 w-3" />
|
||||
{emailCopied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Source Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">{getSourceTypeLabel()}</h4>
|
||||
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">
|
||||
{sourceType === "webhook"
|
||||
? "Click 'Simulate webhook' to load sample fields"
|
||||
: sourceType === "email"
|
||||
? "Click 'Load email fields' to see available fields"
|
||||
: sourceType === "csv"
|
||||
? "Click 'Load sample CSV' to see columns"
|
||||
: "No source fields loaded yet"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sourceFields.map((field) => (
|
||||
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email-specific options */}
|
||||
{sourceType === "email" && onDeriveFromAttachmentsChange && (
|
||||
<div className="mt-4 flex items-center justify-between rounded-lg border border-slate-200 bg-white p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-900">Derive context from attachments</span>
|
||||
<Badge text="AI" type="gray" size="tiny" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
Extract additional context from email attachments using AI
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={deriveFromAttachments} onCheckedChange={onDeriveFromAttachmentsChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Hub Feedback Record Fields</h4>
|
||||
|
||||
{/* Required Fields */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Required</p>
|
||||
{requiredFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Optional Fields */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Optional</p>
|
||||
{optionalFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeField ? (
|
||||
<div className="border-brand-dark rounded-md border bg-white p-2 text-sm shadow-lg">
|
||||
<span className="font-medium">{activeField.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { SOURCE_OPTIONS, TSourceType } from "./types";
|
||||
|
||||
interface SourceTypeSelectorProps {
|
||||
selectedType: TSourceType | null;
|
||||
onSelectType: (type: TSourceType) => void;
|
||||
}
|
||||
|
||||
export function SourceTypeSelector({ selectedType, onSelectType }: SourceTypeSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-600">Select the type of feedback source you want to connect:</p>
|
||||
<div className="space-y-2">
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onSelectType(option.id)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
|
||||
selectedType === option.id
|
||||
? "border-brand-dark bg-slate-50"
|
||||
: option.disabled
|
||||
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
|
||||
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-4 h-5 w-5 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TConnectorWithMappings, TFormbricksConnector } from "@formbricks/types/connector";
|
||||
import {
|
||||
createConnectorAction,
|
||||
deleteConnectorAction,
|
||||
getConnectorsWithMappingsAction,
|
||||
syncFieldMappingsAction,
|
||||
syncFormbricksMappingsAction,
|
||||
updateConnectorAction,
|
||||
} from "@/lib/connector/actions";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
import { getSurveysForUnifyAction } from "../actions";
|
||||
import { CreateSourceModal } from "./create-source-modal";
|
||||
import { EditSourceModal } from "./edit-source-modal";
|
||||
import { SourcesTable } from "./sources-table";
|
||||
import { TSourceConnection, TUnifySurvey } from "./types";
|
||||
import { elementTypeToHubFieldType } from "./types";
|
||||
|
||||
interface SourcesSectionProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
// Transform connector from database to TSourceConnection for UI
|
||||
function connectorToSourceConnection(connector: TConnectorWithMappings): TSourceConnection {
|
||||
// For webhook (and other field-mapping connectors), include field mappings
|
||||
const mappings =
|
||||
connector.type === "webhook" && "fieldMappings" in connector && connector.fieldMappings?.length
|
||||
? connector.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId,
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
type: connector.type as TSourceConnection["type"],
|
||||
mappings,
|
||||
createdAt: connector.createdAt,
|
||||
updatedAt: connector.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Get Formbricks mapping data from a connector
|
||||
function getFormbricksMappingData(connector: TConnectorWithMappings): {
|
||||
surveyId: string | null;
|
||||
elementIds: string[];
|
||||
} {
|
||||
if (connector.type !== "formbricks" || !("formbricksMappings" in connector)) {
|
||||
return { surveyId: null, elementIds: [] };
|
||||
}
|
||||
|
||||
const formbricksConnector = connector as TFormbricksConnector;
|
||||
const mappings = formbricksConnector.formbricksMappings || [];
|
||||
|
||||
if (mappings.length === 0) {
|
||||
return { surveyId: null, elementIds: [] };
|
||||
}
|
||||
|
||||
// All mappings for a Formbricks connector should be for the same survey (for now)
|
||||
const surveyId = mappings[0].surveyId;
|
||||
const elementIds = mappings.map((m) => m.elementId);
|
||||
|
||||
return { surveyId, elementIds };
|
||||
}
|
||||
|
||||
export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
const [sources, setSources] = useState<TSourceConnection[]>([]);
|
||||
const [connectorsMap, setConnectorsMap] = useState<Map<string, TConnectorWithMappings>>(new Map());
|
||||
const [surveys, setSurveys] = useState<TUnifySurvey[]>([]);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingSource, setEditingSource] = useState<TSourceConnection | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Fetch surveys and connectors on mount
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Fetch surveys and connectors in parallel
|
||||
const [surveysResult, connectorsResult] = await Promise.all([
|
||||
getSurveysForUnifyAction({ environmentId }),
|
||||
getConnectorsWithMappingsAction({ environmentId }),
|
||||
]);
|
||||
|
||||
if (surveysResult?.data) {
|
||||
setSurveys(surveysResult.data);
|
||||
}
|
||||
|
||||
if (connectorsResult?.data) {
|
||||
setSources(connectorsResult.data.map(connectorToSourceConnection));
|
||||
// Store the full connector data for editing
|
||||
const newConnectorsMap = new Map<string, TConnectorWithMappings>();
|
||||
connectorsResult.data.forEach((connector) => {
|
||||
newConnectorsMap.set(connector.id, connector);
|
||||
});
|
||||
setConnectorsMap(newConnectorsMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data:", error);
|
||||
toast.error("Failed to load data");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [environmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreateSource = async (
|
||||
source: TSourceConnection,
|
||||
selectedSurveyId?: string,
|
||||
selectedElementIds?: string[]
|
||||
) => {
|
||||
try {
|
||||
// Create the connector in the database
|
||||
const result = await createConnectorAction({
|
||||
environmentId,
|
||||
connectorInput: {
|
||||
name: source.name,
|
||||
type: source.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error("Failed to create connector");
|
||||
return;
|
||||
}
|
||||
|
||||
const connectorResult = result.data;
|
||||
if ("error" in connectorResult && connectorResult.error) {
|
||||
toast.error(connectorResult.error.message || "Failed to create connector");
|
||||
return;
|
||||
}
|
||||
|
||||
const connector = "data" in connectorResult ? connectorResult.data : connectorResult;
|
||||
if (!connector || !connector.id) {
|
||||
toast.error("Failed to create connector - invalid response");
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a Formbricks connector, create the mappings
|
||||
if (
|
||||
source.type === "formbricks" &&
|
||||
selectedSurveyId &&
|
||||
selectedElementIds &&
|
||||
selectedElementIds.length > 0
|
||||
) {
|
||||
// Get the survey to determine element types
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
if (selectedSurvey) {
|
||||
const mappings = selectedElementIds.map((elementId) => {
|
||||
const element = selectedSurvey.elements.find((e) => e.id === elementId);
|
||||
return {
|
||||
surveyId: selectedSurveyId,
|
||||
elementId,
|
||||
hubFieldType: elementTypeToHubFieldType(element?.type || "openText"),
|
||||
};
|
||||
});
|
||||
|
||||
await syncFormbricksMappingsAction({
|
||||
connectorId: connector.id,
|
||||
mappings,
|
||||
});
|
||||
}
|
||||
} else if (source.type !== "formbricks" && source.mappings.length > 0) {
|
||||
// For other connector types, save field mappings
|
||||
const fieldMappings = source.mappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId || "",
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue,
|
||||
}));
|
||||
|
||||
await syncFieldMappingsAction({
|
||||
connectorId: connector.id,
|
||||
mappings: fieldMappings,
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await fetchData();
|
||||
toast.success("Connector created successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to create connector:", error);
|
||||
toast.error("Failed to create connector");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSource = async (
|
||||
updatedSource: TSourceConnection,
|
||||
selectedSurveyId?: string,
|
||||
selectedElementIds?: string[]
|
||||
) => {
|
||||
try {
|
||||
// Update the connector name
|
||||
const result = await updateConnectorAction({
|
||||
connectorId: updatedSource.id,
|
||||
connectorInput: {
|
||||
name: updatedSource.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error("Failed to update connector");
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a Formbricks connector, update the mappings
|
||||
if (
|
||||
updatedSource.type === "formbricks" &&
|
||||
selectedSurveyId &&
|
||||
selectedElementIds &&
|
||||
selectedElementIds.length > 0
|
||||
) {
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
if (selectedSurvey) {
|
||||
const mappings = selectedElementIds.map((elementId) => {
|
||||
const element = selectedSurvey.elements.find((e) => e.id === elementId);
|
||||
return {
|
||||
surveyId: selectedSurveyId,
|
||||
elementId,
|
||||
hubFieldType: elementTypeToHubFieldType(element?.type || "openText"),
|
||||
};
|
||||
});
|
||||
|
||||
await syncFormbricksMappingsAction({
|
||||
connectorId: updatedSource.id,
|
||||
mappings,
|
||||
});
|
||||
}
|
||||
} else if (updatedSource.type !== "formbricks" && updatedSource.mappings.length > 0) {
|
||||
// For other connector types, save field mappings
|
||||
const fieldMappings = updatedSource.mappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId || "",
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue,
|
||||
}));
|
||||
|
||||
await syncFieldMappingsAction({
|
||||
connectorId: updatedSource.id,
|
||||
mappings: fieldMappings,
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await fetchData();
|
||||
toast.success("Connector updated successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to update connector:", error);
|
||||
toast.error("Failed to update connector");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSource = async (sourceId: string) => {
|
||||
try {
|
||||
const result = await deleteConnectorAction({
|
||||
connectorId: sourceId,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error("Failed to delete connector");
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await fetchData();
|
||||
toast.success("Connector deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete connector:", error);
|
||||
toast.error("Failed to delete connector");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSourceClick = (source: TSourceConnection) => {
|
||||
setEditingSource(source);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle="Unify Feedback"
|
||||
cta={
|
||||
<CreateSourceModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateSource={handleCreateSource}
|
||||
surveys={surveys}
|
||||
/>
|
||||
}>
|
||||
<UnifyConfigNavigation environmentId={environmentId} />
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<SourcesTable sources={sources} onSourceClick={handleSourceClick} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
<EditSourceModal
|
||||
source={editingSource}
|
||||
open={editingSource !== null}
|
||||
onOpenChange={(open) => !open && setEditingSource(null)}
|
||||
onUpdateSource={handleUpdateSource}
|
||||
onDeleteSource={handleDeleteSource}
|
||||
surveys={surveys}
|
||||
initialSurveyId={
|
||||
editingSource && connectorsMap.get(editingSource.id)
|
||||
? getFormbricksMappingData(connectorsMap.get(editingSource.id)!).surveyId
|
||||
: null
|
||||
}
|
||||
initialElementIds={
|
||||
editingSource && connectorsMap.get(editingSource.id)
|
||||
? getFormbricksMappingData(connectorsMap.get(editingSource.id)!).elementIds
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { FileSpreadsheetIcon, GlobeIcon, MailIcon, MessageSquareIcon, WebhookIcon } from "lucide-react";
|
||||
import { TSourceType } from "./types";
|
||||
|
||||
interface SourcesTableDataRowProps {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TSourceType;
|
||||
mappingsCount: number;
|
||||
createdAt: Date;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function getSourceIcon(type: TSourceType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "webhook":
|
||||
return <WebhookIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "email":
|
||||
return <MailIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "slack":
|
||||
return <MessageSquareIcon className="h-4 w-4 text-slate-500" />;
|
||||
default:
|
||||
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceTypeLabel(type: TSourceType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "Formbricks";
|
||||
case "webhook":
|
||||
return "Webhook";
|
||||
case "email":
|
||||
return "Email";
|
||||
case "csv":
|
||||
return "CSV";
|
||||
case "slack":
|
||||
return "Slack";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
export function SourcesTableDataRow({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
mappingsCount,
|
||||
createdAt,
|
||||
onClick,
|
||||
}: SourcesTableDataRowProps) {
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onClick();
|
||||
}
|
||||
}}>
|
||||
<div className="col-span-1 flex items-center pl-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getSourceIcon(type)}
|
||||
<span className="hidden text-xs text-slate-500 sm:inline">{getSourceTypeLabel(type)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-6 flex items-center">
|
||||
<span className="truncate font-medium text-slate-900">{name}</span>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
|
||||
{mappingsCount} {mappingsCount === 1 ? "field" : "fields"}
|
||||
</div>
|
||||
<div className="col-span-3 hidden items-center justify-end pr-4 text-sm text-slate-500 sm:flex">
|
||||
{formatDistanceToNow(createdAt, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { SourcesTableDataRow } from "./sources-table-data-row";
|
||||
import { TSourceConnection } from "./types";
|
||||
|
||||
interface SourcesTableProps {
|
||||
sources: TSourceConnection[];
|
||||
onSourceClick: (source: TSourceConnection) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function SourcesTable({ sources, onSourceClick, isLoading = false }: SourcesTableProps) {
|
||||
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-1 pl-6">Type</div>
|
||||
<div className="col-span-6">Name</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">Mappings</div>
|
||||
<div className="col-span-3 hidden pr-6 text-right sm:block">Created</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2Icon className="h-6 w-6 animate-spin text-slate-500" />
|
||||
</div>
|
||||
) : sources.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">No sources connected yet. Add a source to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{sources.map((source) => (
|
||||
<SourcesTableDataRow
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
type={source.type}
|
||||
mappingsCount={source.mappings.length}
|
||||
createdAt={source.createdAt}
|
||||
onClick={() => onSourceClick(source)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
// Source types for the feedback source connections
|
||||
export type TSourceType = "formbricks" | "webhook" | "email" | "csv" | "slack";
|
||||
|
||||
export interface TSourceOption {
|
||||
id: TSourceType;
|
||||
name: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
badge?: {
|
||||
text: string;
|
||||
type: "success" | "gray" | "warning";
|
||||
};
|
||||
}
|
||||
|
||||
export const SOURCE_OPTIONS: TSourceOption[] = [
|
||||
{
|
||||
id: "formbricks",
|
||||
name: "Formbricks Surveys",
|
||||
description: "Connect feedback from your Formbricks surveys",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "webhook",
|
||||
name: "Webhook",
|
||||
description: "Receive feedback via webhook with custom mapping",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
name: "Email",
|
||||
description: "Import feedback from email with custom mapping",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "csv",
|
||||
name: "CSV Import",
|
||||
description: "Import feedback from CSV files",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack Message",
|
||||
description: "Connect feedback from Slack channels",
|
||||
disabled: true,
|
||||
badge: {
|
||||
text: "Coming soon",
|
||||
type: "gray",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Formbricks Survey types for survey selection (legacy - kept for backwards compatibility)
|
||||
export interface TFormbricksSurveyQuestion {
|
||||
id: string;
|
||||
type: "openText" | "rating" | "nps" | "csat" | "multipleChoice" | "checkbox" | "date";
|
||||
headline: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface TFormbricksSurvey {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "draft" | "active" | "paused" | "completed";
|
||||
responseCount: number;
|
||||
questions: TFormbricksSurveyQuestion[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// New Unify Survey types that work with real survey data
|
||||
export interface TUnifySurveyElement {
|
||||
id: string;
|
||||
type: string; // Element type from TSurveyElementTypeEnum
|
||||
headline: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface TUnifySurvey {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "draft" | "active" | "paused" | "completed";
|
||||
responseCount: number;
|
||||
elements: TUnifySurveyElement[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Mock surveys for POC
|
||||
export const MOCK_FORMBRICKS_SURVEYS: TFormbricksSurvey[] = [
|
||||
{
|
||||
id: "survey_nps_q1",
|
||||
name: "Q1 2024 NPS Survey",
|
||||
status: "active",
|
||||
responseCount: 1247,
|
||||
createdAt: new Date("2024-01-15"),
|
||||
questions: [
|
||||
{ id: "q_nps", type: "nps", headline: "How likely are you to recommend us?", required: true },
|
||||
{
|
||||
id: "q_reason",
|
||||
type: "openText",
|
||||
headline: "What's the main reason for your score?",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "q_improve",
|
||||
type: "openText",
|
||||
headline: "What could we do to improve?",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "survey_product_feedback",
|
||||
name: "Product Feedback Survey",
|
||||
status: "active",
|
||||
responseCount: 523,
|
||||
createdAt: new Date("2024-02-01"),
|
||||
questions: [
|
||||
{
|
||||
id: "q_satisfaction",
|
||||
type: "rating",
|
||||
headline: "How satisfied are you with the product?",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q_features",
|
||||
type: "multipleChoice",
|
||||
headline: "Which features do you use most?",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q_missing",
|
||||
type: "openText",
|
||||
headline: "What features are you missing?",
|
||||
required: false,
|
||||
},
|
||||
{ id: "q_feedback", type: "openText", headline: "Any other feedback?", required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "survey_onboarding",
|
||||
name: "Onboarding Experience",
|
||||
status: "active",
|
||||
responseCount: 89,
|
||||
createdAt: new Date("2024-03-10"),
|
||||
questions: [
|
||||
{ id: "q_easy", type: "csat", headline: "How easy was the onboarding process?", required: true },
|
||||
{
|
||||
id: "q_time",
|
||||
type: "multipleChoice",
|
||||
headline: "How long did onboarding take?",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q_help",
|
||||
type: "checkbox",
|
||||
headline: "Which resources did you find helpful?",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "q_suggestions",
|
||||
type: "openText",
|
||||
headline: "Any suggestions for improvement?",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "survey_support",
|
||||
name: "Support Satisfaction",
|
||||
status: "paused",
|
||||
responseCount: 312,
|
||||
createdAt: new Date("2024-01-20"),
|
||||
questions: [
|
||||
{
|
||||
id: "q_support_rating",
|
||||
type: "rating",
|
||||
headline: "How would you rate your support experience?",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "q_resolved",
|
||||
type: "multipleChoice",
|
||||
headline: "Was your issue resolved?",
|
||||
required: true,
|
||||
},
|
||||
{ id: "q_comments", type: "openText", headline: "Additional comments", required: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Helper to get question type label (legacy)
|
||||
export function getQuestionTypeLabel(type: TFormbricksSurveyQuestion["type"]): string {
|
||||
switch (type) {
|
||||
case "openText":
|
||||
return "Open Text";
|
||||
case "rating":
|
||||
return "Rating";
|
||||
case "nps":
|
||||
return "NPS";
|
||||
case "csat":
|
||||
return "CSAT";
|
||||
case "multipleChoice":
|
||||
return "Multiple Choice";
|
||||
case "checkbox":
|
||||
return "Checkbox";
|
||||
case "date":
|
||||
return "Date";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get element type label (for real survey elements)
|
||||
export function getElementTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case "openText":
|
||||
return "Open Text";
|
||||
case "rating":
|
||||
return "Rating";
|
||||
case "nps":
|
||||
return "NPS";
|
||||
case "multipleChoiceSingle":
|
||||
return "Single Choice";
|
||||
case "multipleChoiceMulti":
|
||||
return "Multiple Choice";
|
||||
case "date":
|
||||
return "Date";
|
||||
case "consent":
|
||||
return "Consent";
|
||||
case "matrix":
|
||||
return "Matrix";
|
||||
case "ranking":
|
||||
return "Ranking";
|
||||
case "pictureSelection":
|
||||
return "Picture Selection";
|
||||
case "contactInfo":
|
||||
return "Contact Info";
|
||||
case "address":
|
||||
return "Address";
|
||||
case "fileUpload":
|
||||
return "File Upload";
|
||||
case "cal":
|
||||
return "Calendar";
|
||||
case "cta":
|
||||
return "CTA";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to map question type to FeedbackRecord field_type (legacy)
|
||||
export function questionTypeToFieldType(type: TFormbricksSurveyQuestion["type"]): TFeedbackRecordFieldType {
|
||||
switch (type) {
|
||||
case "openText":
|
||||
return "text";
|
||||
case "rating":
|
||||
return "rating";
|
||||
case "nps":
|
||||
return "nps";
|
||||
case "csat":
|
||||
return "csat";
|
||||
case "multipleChoice":
|
||||
case "checkbox":
|
||||
return "categorical";
|
||||
case "date":
|
||||
return "date";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to map element type to Hub field_type (for real survey elements)
|
||||
export function elementTypeToHubFieldType(type: string): TFeedbackRecordFieldType {
|
||||
switch (type) {
|
||||
case "openText":
|
||||
return "text";
|
||||
case "rating":
|
||||
return "rating";
|
||||
case "nps":
|
||||
return "nps";
|
||||
case "multipleChoiceSingle":
|
||||
case "multipleChoiceMulti":
|
||||
return "categorical";
|
||||
case "date":
|
||||
return "date";
|
||||
case "consent":
|
||||
return "boolean";
|
||||
case "matrix":
|
||||
case "ranking":
|
||||
case "pictureSelection":
|
||||
return "categorical";
|
||||
case "contactInfo":
|
||||
case "address":
|
||||
case "fileUpload":
|
||||
case "cal":
|
||||
return "text";
|
||||
case "cta":
|
||||
return "boolean";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
// Field mapping types - supports both source field mapping and static values
|
||||
export interface TFieldMapping {
|
||||
targetFieldId: string;
|
||||
// Either map from a source field OR set a static value
|
||||
sourceFieldId?: string;
|
||||
staticValue?: string;
|
||||
}
|
||||
|
||||
export interface TSourceConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TSourceType;
|
||||
mappings: TFieldMapping[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// FeedbackRecord field types (enum values for field_type)
|
||||
export type TFeedbackRecordFieldType =
|
||||
| "text"
|
||||
| "categorical"
|
||||
| "nps"
|
||||
| "csat"
|
||||
| "ces"
|
||||
| "rating"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date";
|
||||
|
||||
// Field types for the Hub schema
|
||||
export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]";
|
||||
|
||||
export interface TTargetField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TTargetFieldType;
|
||||
required: boolean;
|
||||
description: string;
|
||||
// For enum fields, the possible values
|
||||
enumValues?: string[];
|
||||
// For string fields, example static values that could be set
|
||||
exampleStaticValues?: string[];
|
||||
}
|
||||
|
||||
export interface TSourceField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
sampleValue?: string;
|
||||
}
|
||||
|
||||
// Enum values for field_type
|
||||
export const FIELD_TYPE_ENUM_VALUES: TFeedbackRecordFieldType[] = [
|
||||
"text",
|
||||
"categorical",
|
||||
"nps",
|
||||
"csat",
|
||||
"ces",
|
||||
"rating",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
];
|
||||
|
||||
// Target fields based on the FeedbackRecord schema
|
||||
export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
|
||||
// Required fields
|
||||
{
|
||||
id: "collected_at",
|
||||
name: "Collected At",
|
||||
type: "timestamp",
|
||||
required: true,
|
||||
description: "When the feedback was originally collected",
|
||||
exampleStaticValues: ["$now"],
|
||||
},
|
||||
{
|
||||
id: "source_type",
|
||||
name: "Source Type",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Type of source (e.g., survey, review, support)",
|
||||
exampleStaticValues: ["survey", "review", "support", "email", "qualtrics", "typeform", "intercom"],
|
||||
},
|
||||
{
|
||||
id: "field_id",
|
||||
name: "Field ID",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Unique question/field identifier",
|
||||
},
|
||||
{
|
||||
id: "field_type",
|
||||
name: "Field Type",
|
||||
type: "enum",
|
||||
required: true,
|
||||
description: "Data type (text, nps, csat, rating, etc.)",
|
||||
enumValues: FIELD_TYPE_ENUM_VALUES,
|
||||
},
|
||||
// Optional fields
|
||||
{
|
||||
id: "tenant_id",
|
||||
name: "Tenant ID",
|
||||
type: "string",
|
||||
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",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Reference to survey/form/ticket/review ID",
|
||||
},
|
||||
{
|
||||
id: "source_name",
|
||||
name: "Source Name",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Human-readable source name for display",
|
||||
exampleStaticValues: ["Product Feedback", "Customer Support", "NPS Survey", "Qualtrics Import"],
|
||||
},
|
||||
{
|
||||
id: "field_label",
|
||||
name: "Field Label",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Question text or field label for display",
|
||||
},
|
||||
{
|
||||
id: "value_text",
|
||||
name: "Value (Text)",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Text responses (feedback, comments, open-ended answers)",
|
||||
},
|
||||
{
|
||||
id: "value_number",
|
||||
name: "Value (Number)",
|
||||
type: "float64",
|
||||
required: false,
|
||||
description: "Numeric responses (ratings, scores, NPS, CSAT)",
|
||||
},
|
||||
{
|
||||
id: "value_boolean",
|
||||
name: "Value (Boolean)",
|
||||
type: "boolean",
|
||||
required: false,
|
||||
description: "Yes/no responses",
|
||||
},
|
||||
{
|
||||
id: "value_date",
|
||||
name: "Value (Date)",
|
||||
type: "timestamp",
|
||||
required: false,
|
||||
description: "Date/datetime responses",
|
||||
},
|
||||
{
|
||||
id: "metadata",
|
||||
name: "Metadata",
|
||||
type: "jsonb",
|
||||
required: false,
|
||||
description: "Flexible context (device, location, campaign, custom fields)",
|
||||
},
|
||||
{
|
||||
id: "language",
|
||||
name: "Language",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "ISO 639-1 language code (e.g., en, de, fr)",
|
||||
exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"],
|
||||
},
|
||||
{
|
||||
id: "user_identifier",
|
||||
name: "User Identifier",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Anonymous user ID for tracking (hashed, never PII)",
|
||||
},
|
||||
];
|
||||
|
||||
// Sample webhook payload for testing
|
||||
export const SAMPLE_WEBHOOK_PAYLOAD = {
|
||||
id: "resp_12345",
|
||||
timestamp: "2024-01-15T10:30:00Z",
|
||||
survey_id: "survey_abc",
|
||||
survey_name: "Product Feedback Survey",
|
||||
question_id: "q1",
|
||||
question_text: "How satisfied are you with our product?",
|
||||
answer_type: "rating",
|
||||
answer_value: 4,
|
||||
user_id: "user_xyz",
|
||||
metadata: {
|
||||
device: "mobile",
|
||||
browser: "Safari",
|
||||
},
|
||||
};
|
||||
|
||||
// Email source fields (simplified)
|
||||
export const EMAIL_SOURCE_FIELDS: TSourceField[] = [
|
||||
{ id: "subject", name: "Subject", type: "string", sampleValue: "Feature Request: Dark Mode" },
|
||||
{
|
||||
id: "body",
|
||||
name: "Body (Text)",
|
||||
type: "string",
|
||||
sampleValue: "I would love to see a dark mode option...",
|
||||
},
|
||||
];
|
||||
|
||||
// CSV sample columns
|
||||
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
|
||||
|
||||
// Helper function to parse payload to source fields
|
||||
export function parsePayloadToFields(payload: Record<string, unknown>): TSourceField[] {
|
||||
const fields: TSourceField[] = [];
|
||||
|
||||
function extractFields(obj: Record<string, unknown>, prefix = ""): void {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fieldId = prefix ? `${prefix}.${key}` : key;
|
||||
const fieldName = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
fields.push({
|
||||
id: fieldId,
|
||||
name: fieldName,
|
||||
type: "string",
|
||||
sampleValue: String(value),
|
||||
});
|
||||
} else if (Array.isArray(value)) {
|
||||
// Handle arrays - expand first few elements with index notation
|
||||
if (value.length === 0) {
|
||||
fields.push({
|
||||
id: fieldId,
|
||||
name: fieldName,
|
||||
type: "array",
|
||||
sampleValue: "[]",
|
||||
});
|
||||
} else {
|
||||
// Expand up to first 3 array elements
|
||||
const maxItems = Math.min(value.length, 3);
|
||||
for (let i = 0; i < maxItems; i++) {
|
||||
const item = value[i];
|
||||
const itemPrefix = `${fieldId}[${i}]`;
|
||||
|
||||
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
|
||||
// Array of objects - expand the object properties
|
||||
extractFields(item as Record<string, unknown>, itemPrefix);
|
||||
} else if (Array.isArray(item)) {
|
||||
// Nested array
|
||||
fields.push({
|
||||
id: itemPrefix,
|
||||
name: itemPrefix,
|
||||
type: "array",
|
||||
sampleValue: `[${item.length} items]`,
|
||||
});
|
||||
} else {
|
||||
// Primitive array element
|
||||
let type = "string";
|
||||
if (typeof item === "number") type = "number";
|
||||
if (typeof item === "boolean") type = "boolean";
|
||||
|
||||
fields.push({
|
||||
id: itemPrefix,
|
||||
name: itemPrefix,
|
||||
type,
|
||||
sampleValue: String(item),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If there are more items, indicate that
|
||||
if (value.length > 3) {
|
||||
fields.push({
|
||||
id: `${fieldId}[...]`,
|
||||
name: `${fieldId}[...]`,
|
||||
type: "info",
|
||||
sampleValue: `+${value.length - 3} more items`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
// Handle nested objects
|
||||
extractFields(value as Record<string, unknown>, fieldId);
|
||||
} else {
|
||||
// Handle primitives
|
||||
let type = "string";
|
||||
if (typeof value === "number") type = "number";
|
||||
if (typeof value === "boolean") type = "boolean";
|
||||
|
||||
fields.push({
|
||||
id: fieldId,
|
||||
name: fieldName,
|
||||
type,
|
||||
sampleValue: String(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extractFields(payload);
|
||||
return fields;
|
||||
}
|
||||
|
||||
// Helper function to parse CSV columns to source fields
|
||||
export function parseCSVColumnsToFields(columns: string): TSourceField[] {
|
||||
return columns.split(",").map((col) => {
|
||||
const trimmedCol = col.trim();
|
||||
return {
|
||||
id: trimmedCol,
|
||||
name: trimmedCol,
|
||||
type: "string",
|
||||
sampleValue: `Sample ${trimmedCol}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// AI suggested mappings for different source types
|
||||
// Maps source field IDs to target field IDs
|
||||
export interface TAISuggestedMapping {
|
||||
// Maps source field ID -> target field ID
|
||||
fieldMappings: Record<string, string>;
|
||||
// Static values to set on target fields
|
||||
staticValues: Record<string, string>;
|
||||
}
|
||||
|
||||
export const AI_SUGGESTED_MAPPINGS: Record<TSourceType, TAISuggestedMapping> = {
|
||||
webhook: {
|
||||
fieldMappings: {
|
||||
timestamp: "collected_at",
|
||||
survey_id: "source_id",
|
||||
survey_name: "source_name",
|
||||
question_id: "field_id",
|
||||
question_text: "field_label",
|
||||
answer_value: "value_number",
|
||||
user_id: "user_identifier",
|
||||
},
|
||||
staticValues: {
|
||||
source_type: "survey",
|
||||
field_type: "rating",
|
||||
},
|
||||
},
|
||||
email: {
|
||||
fieldMappings: {
|
||||
subject: "field_label",
|
||||
body: "value_text",
|
||||
},
|
||||
staticValues: {
|
||||
collected_at: "$now",
|
||||
source_type: "email",
|
||||
field_type: "text",
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
fieldMappings: {
|
||||
timestamp: "collected_at",
|
||||
customer_id: "user_identifier",
|
||||
rating: "value_number",
|
||||
feedback_text: "value_text",
|
||||
category: "field_label",
|
||||
},
|
||||
staticValues: {
|
||||
source_type: "survey",
|
||||
field_type: "rating",
|
||||
},
|
||||
},
|
||||
formbricks: {
|
||||
fieldMappings: {},
|
||||
staticValues: {
|
||||
source_type: "survey",
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
fieldMappings: {},
|
||||
staticValues: {
|
||||
source_type: "support",
|
||||
field_type: "text",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Modal step types
|
||||
export type TCreateSourceStep = "selectType" | "mapping";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { SourcesSection } from "./components/sources-page-client";
|
||||
|
||||
export default async function UnifySourcesPage(props: { params: Promise<{ environmentId: string }> }) {
|
||||
const params = await props.params;
|
||||
|
||||
await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
return <SourcesSection environmentId={params.environmentId} />;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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[];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
@@ -138,6 +139,14 @@ export const POST = async (request: Request) => {
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Handle connector pipeline for Hub integration (only on responseFinished to avoid duplicates)
|
||||
// This sends response data to the Hub for configured connectors
|
||||
try {
|
||||
await handleConnectorPipeline(response, survey, environmentId);
|
||||
} catch (error) {
|
||||
// Log but don't throw - connector failures shouldn't break the main pipeline
|
||||
logger.error({ error, surveyId, responseId: response.id }, "Connector pipeline failed");
|
||||
}
|
||||
// Fetch integrations and responseCount in parallel
|
||||
const [integrations, responseCount] = await Promise.all([
|
||||
getIntegrations(environmentId),
|
||||
|
||||
51
apps/web/app/api/analytics/_lib/cube-client.ts
Normal file
51
apps/web/app/api/analytics/_lib/cube-client.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
286
apps/web/app/api/analytics/_lib/schema-parser.ts
Normal file
286
apps/web/app/api/analytics/_lib/schema-parser.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Parses the Cube.js schema file to extract measures and dimensions
|
||||
* This keeps the schema as the single source of truth for AI query generation
|
||||
*/
|
||||
|
||||
interface MeasureInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DimensionInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
type: "string" | "number" | "time";
|
||||
}
|
||||
|
||||
// Path to schema file - self-contained within the analytics folder
|
||||
const SCHEMA_FILE_PATH = path.join(process.cwd(), "app", "api", "analytics", "_schema", "FeedbackRecords.js");
|
||||
|
||||
/**
|
||||
* Extract description from a schema property object
|
||||
*/
|
||||
function extractDescription(objStr: string): string {
|
||||
const descMatch = objStr.match(/description:\s*`([^`]+)`/);
|
||||
return descMatch ? descMatch[1] : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract type from a dimension object
|
||||
*/
|
||||
function extractType(objStr: string): "string" | "number" | "time" {
|
||||
const typeMatch = objStr.match(/type:\s*`(string|number|time)`/);
|
||||
return (typeMatch?.[1] as "string" | "number" | "time") || "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract content inside the first matching brace block
|
||||
*/
|
||||
function extractInnerBlockContent(content: string, startRegex: RegExp): string | null {
|
||||
const match = content.match(startRegex);
|
||||
if (!match) return null;
|
||||
|
||||
// Backtrack to find the opening brace in the match
|
||||
const braceIndex = match[0].lastIndexOf("{");
|
||||
if (braceIndex === -1) return null; // Should not happen given regex usage
|
||||
|
||||
// Actually we can just start scanning from the end of the match if the regex ends with {
|
||||
// But let's be safer: start counting from the opening brace.
|
||||
const absoluteStartIndex = match.index! + braceIndex;
|
||||
|
||||
let braceCount = 1;
|
||||
let i = absoluteStartIndex + 1;
|
||||
|
||||
while (braceCount > 0 && i < content.length) {
|
||||
if (content[i] === "{") braceCount++;
|
||||
else if (content[i] === "}") braceCount--;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (braceCount === 0) {
|
||||
return content.substring(absoluteStartIndex + 1, i - 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse measures from the schema file
|
||||
*/
|
||||
function parseMeasures(schemaContent: string): MeasureInfo[] {
|
||||
const measures: MeasureInfo[] = [];
|
||||
|
||||
const measuresBlock = extractInnerBlockContent(schemaContent, /measures:\s*\{/);
|
||||
if (!measuresBlock) return measures;
|
||||
|
||||
// Match each measure: measureName: { ... }
|
||||
const measureRegex = /(\w+):\s*\{/g;
|
||||
let match;
|
||||
|
||||
while ((match = measureRegex.exec(measuresBlock)) !== null) {
|
||||
const name = match[1];
|
||||
const startIndex = match.index + match[0].length;
|
||||
|
||||
// Find the matching closing brace
|
||||
let braceCount = 1;
|
||||
let endIndex = startIndex;
|
||||
|
||||
while (braceCount > 0 && endIndex < measuresBlock.length) {
|
||||
if (measuresBlock[endIndex] === "{") braceCount++;
|
||||
if (measuresBlock[endIndex] === "}") braceCount--;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
const body = measuresBlock.substring(startIndex, endIndex - 1);
|
||||
const description = extractDescription(body);
|
||||
|
||||
if (description) {
|
||||
measures.push({ name, description });
|
||||
}
|
||||
}
|
||||
|
||||
return measures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dimensions from a specific cube
|
||||
*/
|
||||
function parseDimensionsFromCube(cubeContent: string, cubeName: string): DimensionInfo[] {
|
||||
const dimensions: DimensionInfo[] = [];
|
||||
|
||||
const dimensionsBlock = extractInnerBlockContent(cubeContent, /dimensions:\s*\{/);
|
||||
if (!dimensionsBlock) return dimensions;
|
||||
|
||||
// Match each dimension: dimensionName: { ... }
|
||||
const dimensionRegex = /(\w+):\s*\{/g;
|
||||
let match;
|
||||
|
||||
while ((match = dimensionRegex.exec(dimensionsBlock)) !== null) {
|
||||
const name = match[1];
|
||||
const startIndex = match.index + match[0].length;
|
||||
|
||||
// Find the matching closing brace
|
||||
let braceCount = 1;
|
||||
let endIndex = startIndex;
|
||||
|
||||
while (braceCount > 0 && endIndex < dimensionsBlock.length) {
|
||||
if (dimensionsBlock[endIndex] === "{") braceCount++;
|
||||
if (dimensionsBlock[endIndex] === "}") braceCount--;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
const body = dimensionsBlock.substring(startIndex, endIndex - 1);
|
||||
const description = extractDescription(body);
|
||||
const type = extractType(body);
|
||||
|
||||
// Skip primaryKey dimensions (like 'id') and internal dimensions
|
||||
if (body.includes("primaryKey: true") || name === "feedbackRecordId") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (description) {
|
||||
dimensions.push({
|
||||
name: cubeName === "FeedbackRecords" ? name : `${cubeName}.${name}`,
|
||||
description,
|
||||
type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dimensions from the schema file
|
||||
*/
|
||||
function parseDimensions(schemaContent: string): DimensionInfo[] {
|
||||
const dimensions: DimensionInfo[] = [];
|
||||
|
||||
// Extract dimensions from FeedbackRecords cube
|
||||
const feedbackRecordsMatch = schemaContent.match(/cube\(`FeedbackRecords`,\s*\{([\s\S]*?)\n\}\);/);
|
||||
if (feedbackRecordsMatch) {
|
||||
const feedbackRecordsDimensions = parseDimensionsFromCube(feedbackRecordsMatch[1], "FeedbackRecords");
|
||||
dimensions.push(...feedbackRecordsDimensions);
|
||||
}
|
||||
|
||||
// Extract dimensions from TopicsUnnested cube
|
||||
const topicsUnnestedMatch = schemaContent.match(/cube\(`TopicsUnnested`,\s*\{([\s\S]*?)\n\}\);/);
|
||||
if (topicsUnnestedMatch) {
|
||||
const topicsDimensions = parseDimensionsFromCube(topicsUnnestedMatch[1], "TopicsUnnested");
|
||||
dimensions.push(...topicsDimensions);
|
||||
}
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse the schema file
|
||||
*/
|
||||
export function parseSchemaFile(): {
|
||||
measures: MeasureInfo[];
|
||||
dimensions: DimensionInfo[];
|
||||
} {
|
||||
try {
|
||||
const schemaContent = fs.readFileSync(SCHEMA_FILE_PATH, "utf-8");
|
||||
const measures = parseMeasures(schemaContent);
|
||||
const dimensions = parseDimensions(schemaContent);
|
||||
|
||||
return { measures, dimensions };
|
||||
} catch (error) {
|
||||
console.error("Error parsing schema file:", error);
|
||||
// Fallback to empty arrays if parsing fails
|
||||
return { measures: [], dimensions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the schema context string for AI query generation
|
||||
*/
|
||||
export function generateSchemaContext(): string {
|
||||
const { measures, dimensions } = parseSchemaFile();
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
const measuresList = measures.map((m) => `- ${CUBE_NAME}.${m.name} - ${m.description}`).join("\n");
|
||||
|
||||
const dimensionsList = dimensions
|
||||
.map((d) => {
|
||||
const typeLabel = d.type === "time" ? " (time dimension)" : ` (${d.type})`;
|
||||
// Dimensions from TopicsUnnested already have the cube prefix
|
||||
const fullName = d.name.includes(".") ? d.name : `${CUBE_NAME}.${d.name}`;
|
||||
return `- ${fullName} - ${d.description}${typeLabel}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const categoricalDimensions = dimensions
|
||||
.filter(
|
||||
(d) =>
|
||||
d.type === "string" &&
|
||||
!d.name.includes("responseId") &&
|
||||
!d.name.includes("userIdentifier") &&
|
||||
!d.name.includes("feedbackRecordId")
|
||||
)
|
||||
.map((d) => (d.name.includes(".") ? d.name : `${CUBE_NAME}.${d.name}`))
|
||||
.join(", ");
|
||||
|
||||
return `
|
||||
You are a CubeJS query generator. Your task is to convert natural language requests into valid CubeJS query JSON objects.
|
||||
|
||||
Available Cubes: ${CUBE_NAME}, TopicsUnnested
|
||||
|
||||
MEASURES (use these in the "measures" array):
|
||||
${measuresList}
|
||||
|
||||
DIMENSIONS (use these in the "dimensions" array):
|
||||
${dimensionsList}
|
||||
|
||||
TIME DIMENSIONS:
|
||||
- ${CUBE_NAME}.collectedAt can be used with granularity: 'day', 'week', 'month', 'year'
|
||||
- Use "timeDimensions" array for time-based queries with dateRange like "last 7 days", "last 30 days", "this month", etc.
|
||||
|
||||
CHART TYPE SUGGESTIONS:
|
||||
- If query has timeDimensions → suggest "bar" or "line"
|
||||
- If query has categorical dimensions (${categoricalDimensions}) → suggest "donut" or "bar"
|
||||
- If query has only measures → suggest "kpi"
|
||||
- If query compares multiple measures → suggest "bar"
|
||||
|
||||
FILTERS:
|
||||
- Use "filters" array to include/exclude records based on dimension values
|
||||
- Filter format: { "member": "CubeName.dimensionName", "operator": "operator" } OR { "member": "CubeName.dimensionName", "operator": "operator", "values": [...] }
|
||||
- Common operators:
|
||||
* "set" - dimension is not null/empty (Set "values" to null)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
|
||||
* "notSet" - dimension is null/empty (Set "values" to null)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "notSet", "values": null }
|
||||
* "equals" - exact match (REQUIRES "values" field)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "equals", "values": ["happy"] }
|
||||
* "notEquals" - not equal (REQUIRES "values" field)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "notEquals", "values": ["sad"] }
|
||||
* "contains" - contains text (REQUIRES "values" field)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "contains", "values": ["happy"] }
|
||||
- Examples for common user requests:
|
||||
* "only records with emotion" or "for records that have emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
|
||||
* "exclude records without emotion" or "do not include records without emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
|
||||
* "exclude records with emotion" or "do not include records with emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "notSet", "values": null }
|
||||
* "only happy emotions" → { "member": "${CUBE_NAME}.emotion", "operator": "equals", "values": ["happy"] }
|
||||
|
||||
IMPORTANT RULES:
|
||||
1. Always return valid JSON only, no markdown or code blocks
|
||||
2. Use exact measure/dimension names as listed above
|
||||
3. Include "chartType" field: "bar", "line", "donut", "kpi", or "area"
|
||||
4. For time queries, use timeDimensions array with granularity and dateRange
|
||||
5. Return format: { "measures": [...], "dimensions": [...], "timeDimensions": [...], "filters": [...], "chartType": "..." }
|
||||
6. If user asks about trends over time, use timeDimensions
|
||||
7. If user asks "by X", add X as a dimension
|
||||
8. If user asks for counts or totals, use ${CUBE_NAME}.count
|
||||
9. If user asks for NPS, use ${CUBE_NAME}.npsScore
|
||||
10. If user asks about topics, use TopicsUnnested.topic (NOT ${CUBE_NAME}.topic)
|
||||
11. CRITICAL: If user says "only records with X", "exclude records without X", or "for records that have X", add a filter with operator "set" for that dimension
|
||||
12. CRITICAL: If user says "exclude records with X", "do not include records with X", or "without X", add a filter with operator "notSet" for that dimension
|
||||
13. Always include filters when user explicitly mentions including/excluding records based on dimension values
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export const CUBE_NAME = "FeedbackRecords";
|
||||
39
apps/web/app/api/analytics/_lib/types.ts
Normal file
39
apps/web/app/api/analytics/_lib/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
156
apps/web/app/api/analytics/_schema/FeedbackRecords.js
Normal file
156
apps/web/app/api/analytics/_schema/FeedbackRecords.js
Normal file
@@ -0,0 +1,156 @@
|
||||
cube(`FeedbackRecords`, {
|
||||
sql: `SELECT * FROM feedback_records`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
description: `Total number of feedback responses`,
|
||||
},
|
||||
|
||||
promoterCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||
description: `Number of promoters (NPS score 9-10)`,
|
||||
},
|
||||
|
||||
detractorCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number <= 6` }],
|
||||
description: `Number of detractors (NPS score 0-6)`,
|
||||
},
|
||||
|
||||
passiveCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||
description: `Number of passives (NPS score 7-8)`,
|
||||
},
|
||||
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||
},
|
||||
|
||||
averageScore: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
description: `Average NPS score`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `id`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
sentiment: {
|
||||
sql: `sentiment`,
|
||||
type: `string`,
|
||||
description: `Sentiment extracted from metadata JSONB field`,
|
||||
},
|
||||
|
||||
sourceType: {
|
||||
sql: `source_type`,
|
||||
type: `string`,
|
||||
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
|
||||
},
|
||||
|
||||
sourceName: {
|
||||
sql: `source_name`,
|
||||
type: `string`,
|
||||
description: `Human-readable name of the source`,
|
||||
},
|
||||
|
||||
fieldType: {
|
||||
sql: `field_type`,
|
||||
type: `string`,
|
||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||
},
|
||||
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback was collected`,
|
||||
},
|
||||
|
||||
npsValue: {
|
||||
sql: `value_number`,
|
||||
type: `number`,
|
||||
description: `Raw NPS score value (0-10)`,
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `response_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
},
|
||||
|
||||
userIdentifier: {
|
||||
sql: `user_identifier`,
|
||||
type: `string`,
|
||||
description: `Identifier of the user who provided feedback`,
|
||||
},
|
||||
|
||||
emotion: {
|
||||
sql: `emotion`,
|
||||
type: `string`,
|
||||
description: `Emotion extracted from metadata JSONB field`,
|
||||
},
|
||||
},
|
||||
|
||||
joins: {
|
||||
TopicsUnnested: {
|
||||
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
|
||||
relationship: `hasMany`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cube(`TopicsUnnested`, {
|
||||
sql: `
|
||||
SELECT
|
||||
fr.id as feedback_record_id,
|
||||
topic_elem.topic
|
||||
FROM feedback_records fr
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
|
||||
`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `feedback_record_id || '-' || topic`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
feedbackRecordId: {
|
||||
sql: `feedback_record_id`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
topic: {
|
||||
sql: `topic`,
|
||||
type: `string`,
|
||||
description: `Individual topic from the topics array`,
|
||||
},
|
||||
},
|
||||
});
|
||||
246
apps/web/app/api/analytics/query/route.ts
Normal file
246
apps/web/app/api/analytics/query/route.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
102
apps/web/app/api/unify/webhook-listener/[sessionId]/route.ts
Normal file
102
apps/web/app/api/unify/webhook-listener/[sessionId]/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getPayload, storePayload } from "@/lib/connector/webhook-listener-store";
|
||||
|
||||
// Maximum payload size in bytes (100KB)
|
||||
const MAX_PAYLOAD_SIZE = 100 * 1024;
|
||||
|
||||
/**
|
||||
* POST /api/unify/webhook-listener/[sessionId]
|
||||
* Receive an incoming webhook payload for testing
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const { sessionId } = await props.params;
|
||||
|
||||
if (!sessionId || sessionId.length < 10) {
|
||||
return NextResponse.json({ error: "Invalid session ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check content length
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_PAYLOAD_SIZE) {
|
||||
return NextResponse.json({ error: "Payload too large. Maximum size is 100KB." }, { status: 413 });
|
||||
}
|
||||
|
||||
// Parse the JSON payload
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate payload is an object
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return NextResponse.json({ error: "Payload must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Store the payload
|
||||
const stored = storePayload(sessionId, payload);
|
||||
if (!stored) {
|
||||
return NextResponse.json({ error: "Failed to store payload. It may be too large." }, { status: 413 });
|
||||
}
|
||||
|
||||
logger.info({ sessionId }, "Webhook payload received for session");
|
||||
|
||||
return NextResponse.json({ success: true, message: "Webhook received successfully" }, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, "Error processing webhook payload");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/unify/webhook-listener/[sessionId]
|
||||
* Poll for a received webhook payload
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
props: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const { sessionId } = await props.params;
|
||||
|
||||
if (!sessionId || sessionId.length < 10) {
|
||||
return NextResponse.json({ error: "Invalid session ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the payload (and clear it from the store)
|
||||
const payload = getPayload(sessionId, true);
|
||||
|
||||
if (!payload) {
|
||||
// No payload received yet - return 204 No Content
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
// Return the payload
|
||||
return NextResponse.json({ payload }, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, "Error retrieving webhook payload");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/unify/webhook-listener/[sessionId]
|
||||
* Handle CORS preflight requests
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
276
apps/web/app/api/unify/webhook/[connectorId]/route.ts
Normal file
276
apps/web/app/api/unify/webhook/[connectorId]/route.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TCreateFeedbackRecordInput, createFeedbackRecordsBatch } from "@/lib/connector/hub-client";
|
||||
import { getPayload, storePayload } from "@/lib/connector/webhook-listener-store";
|
||||
|
||||
// Maximum payload size in bytes (100KB)
|
||||
const MAX_PAYLOAD_SIZE = 100 * 1024;
|
||||
|
||||
/**
|
||||
* POST /api/unify/webhook/[connectorId]
|
||||
*
|
||||
* Receive incoming webhook payloads for a connector.
|
||||
* - If connector has no field mappings yet: Store payload for setup UI
|
||||
* - If connector has field mappings: Process and send to Hub
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ connectorId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const { connectorId } = await props.params;
|
||||
|
||||
if (!connectorId) {
|
||||
return NextResponse.json({ error: "Connector ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check content length
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_PAYLOAD_SIZE) {
|
||||
return NextResponse.json({ error: "Payload too large. Maximum size is 100KB." }, { status: 413 });
|
||||
}
|
||||
|
||||
// Parse the JSON payload
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate payload is an object
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return NextResponse.json({ error: "Payload must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch connector with its field mappings
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: { id: connectorId },
|
||||
include: {
|
||||
fieldMappings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!connector) {
|
||||
return NextResponse.json({ error: "Connector not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (connector.type !== "webhook") {
|
||||
return NextResponse.json({ error: "This endpoint is only for webhook connectors" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (connector.status !== "active") {
|
||||
return NextResponse.json({ error: "Connector is not active" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if connector has field mappings configured
|
||||
const hasMappings = connector.fieldMappings && connector.fieldMappings.length > 0;
|
||||
|
||||
if (!hasMappings) {
|
||||
// Setup phase: Store payload for UI to fetch
|
||||
const stored = storePayload(connectorId, payload);
|
||||
if (!stored) {
|
||||
return NextResponse.json({ error: "Failed to store payload. It may be too large." }, { status: 413 });
|
||||
}
|
||||
|
||||
logger.info({ connectorId }, "Webhook payload stored for setup");
|
||||
return NextResponse.json(
|
||||
{ success: true, message: "Payload received for setup", mode: "setup" },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Production phase: Transform and send to Hub
|
||||
const feedbackRecords: TCreateFeedbackRecordInput[] = [];
|
||||
|
||||
// Build a single feedback record from the payload using field mappings
|
||||
const record: Record<string, unknown> = {};
|
||||
|
||||
for (const mapping of connector.fieldMappings) {
|
||||
let value: unknown;
|
||||
|
||||
if (mapping.staticValue) {
|
||||
// Use static value
|
||||
value = mapping.staticValue;
|
||||
// Handle special static values
|
||||
if (value === "$now") {
|
||||
value = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
// Get value from payload using dot notation path
|
||||
value = getNestedValue(payload, mapping.sourceFieldId);
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
record[mapping.targetFieldId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure required fields have defaults
|
||||
if (!record.source_type) {
|
||||
record.source_type = "webhook";
|
||||
}
|
||||
if (!record.collected_at) {
|
||||
record.collected_at = new Date().toISOString();
|
||||
}
|
||||
if (!record.field_type) {
|
||||
record.field_type = "text";
|
||||
}
|
||||
if (!record.field_id) {
|
||||
record.field_id = connectorId;
|
||||
}
|
||||
|
||||
// Add environment as tenant
|
||||
record.tenant_id = connector.environmentId;
|
||||
|
||||
feedbackRecords.push(record as TCreateFeedbackRecordInput);
|
||||
|
||||
// Send to Hub
|
||||
const { results } = await createFeedbackRecordsBatch(feedbackRecords);
|
||||
|
||||
const successCount = results.filter((r) => r.data && !r.error).length;
|
||||
const errorCount = results.filter((r) => r.error).length;
|
||||
|
||||
if (errorCount > 0) {
|
||||
logger.error(
|
||||
{ connectorId, errors: results.filter((r) => r.error).map((r) => r.error) },
|
||||
"Some feedback records failed to create"
|
||||
);
|
||||
}
|
||||
|
||||
// Update connector last sync time
|
||||
await prisma.connector.update({
|
||||
where: { id: connectorId },
|
||||
data: {
|
||||
lastSyncAt: new Date(),
|
||||
errorMessage: errorCount > 0 ? `${errorCount} records failed` : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ connectorId, successCount, errorCount }, "Webhook processed");
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "Webhook processed",
|
||||
mode: "production",
|
||||
records_created: successCount,
|
||||
records_failed: errorCount,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ error, connectorId }, "Error processing webhook");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/unify/webhook/[connectorId]
|
||||
*
|
||||
* Poll for a received webhook payload during setup phase.
|
||||
* Returns the stored payload if one exists.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
props: { params: Promise<{ connectorId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const { connectorId } = await props.params;
|
||||
|
||||
if (!connectorId) {
|
||||
return NextResponse.json({ error: "Connector ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify connector exists
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: { id: connectorId },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
|
||||
if (!connector) {
|
||||
return NextResponse.json({ error: "Connector not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (connector.type !== "webhook") {
|
||||
return NextResponse.json({ error: "This endpoint is only for webhook connectors" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the stored payload (and clear it)
|
||||
const payload = getPayload(connectorId, true);
|
||||
|
||||
if (!payload) {
|
||||
// No payload received yet
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ payload }, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error({ error, connectorId }, "Error retrieving webhook payload");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/unify/webhook/[connectorId]
|
||||
* Handle CORS preflight requests
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a nested value from an object using dot notation and array brackets
|
||||
* Supports paths like: "form_response.answers[0].text" or "data.items[2].name"
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
let current: unknown = obj;
|
||||
|
||||
// Split by dots, but we need to handle array notation within each segment
|
||||
const segments = path.split(".");
|
||||
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if segment contains array notation like "answers[0]" or just "[0]"
|
||||
const arrayMatch = segment.match(/^([^\[]*)\[(\d+)\]$/);
|
||||
|
||||
if (arrayMatch) {
|
||||
const [, propertyName, indexStr] = arrayMatch;
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
// If there's a property name before the bracket, access it first
|
||||
if (propertyName) {
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[propertyName];
|
||||
}
|
||||
|
||||
// Now access the array index
|
||||
if (!Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[index];
|
||||
} else {
|
||||
// Regular property access
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
@@ -71,12 +71,6 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Response", responseId, true),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response is already finished", undefined, true),
|
||||
|
||||
@@ -9,18 +9,17 @@
|
||||
"source": "en-US",
|
||||
"targets": [
|
||||
"de-DE",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hu-HU",
|
||||
"ja-JP",
|
||||
"nl-NL",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW"
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
"ru-RU"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
454
apps/web/lib/connector/actions.ts
Normal file
454
apps/web/lib/connector/actions.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
TConnector,
|
||||
TConnectorFieldMapping,
|
||||
TConnectorFormbricksMapping,
|
||||
TConnectorWithMappings,
|
||||
ZConnectorCreateInput,
|
||||
ZConnectorFieldMappingCreateInput,
|
||||
ZConnectorFormbricksMappingCreateInput,
|
||||
ZConnectorUpdateInput,
|
||||
} from "@formbricks/types/connector";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import {
|
||||
getOrganizationIdFromConnectorId,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getProjectIdFromConnectorId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
} from "@/lib/utils/helper";
|
||||
import {
|
||||
createConnector,
|
||||
createFieldMappings,
|
||||
createFormbricksMappings,
|
||||
deleteConnector,
|
||||
deleteFieldMapping,
|
||||
deleteFormbricksMapping,
|
||||
getConnector,
|
||||
getConnectorWithMappings,
|
||||
getConnectors,
|
||||
getConnectorsWithMappings,
|
||||
syncFieldMappings,
|
||||
syncFormbricksMappings,
|
||||
updateConnector,
|
||||
} from "./service";
|
||||
|
||||
// Get all connectors for an environment
|
||||
const ZGetConnectorsAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getConnectorsAction = authenticatedActionClient
|
||||
.schema(ZGetConnectorsAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TConnector[]> => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getConnectors(parsedInput.environmentId);
|
||||
});
|
||||
|
||||
// Get all connectors with mappings for an environment
|
||||
const ZGetConnectorsWithMappingsAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getConnectorsWithMappingsAction = authenticatedActionClient
|
||||
.schema(ZGetConnectorsWithMappingsAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings[]> => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getConnectorsWithMappings(parsedInput.environmentId);
|
||||
});
|
||||
|
||||
// Get a single connector with mappings
|
||||
const ZGetConnectorWithMappingsAction = z.object({
|
||||
connectorId: ZId,
|
||||
});
|
||||
|
||||
export const getConnectorWithMappingsAction = authenticatedActionClient
|
||||
.schema(ZGetConnectorWithMappingsAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings | null> => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getConnectorWithMappings(parsedInput.connectorId);
|
||||
});
|
||||
|
||||
// Create a new connector
|
||||
const ZCreateConnectorAction = z.object({
|
||||
environmentId: ZId,
|
||||
connectorInput: ZConnectorCreateInput,
|
||||
});
|
||||
|
||||
export const createConnectorAction = authenticatedActionClient
|
||||
.schema(ZCreateConnectorAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateConnectorAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return createConnector(parsedInput.environmentId, parsedInput.connectorInput);
|
||||
}
|
||||
);
|
||||
|
||||
// Update a connector
|
||||
const ZUpdateConnectorAction = z.object({
|
||||
connectorId: ZId,
|
||||
connectorInput: ZConnectorUpdateInput,
|
||||
});
|
||||
|
||||
export const updateConnectorAction = authenticatedActionClient
|
||||
.schema(ZUpdateConnectorAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateConnectorAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return updateConnector(parsedInput.connectorId, parsedInput.connectorInput);
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a connector
|
||||
const ZDeleteConnectorAction = z.object({
|
||||
connectorId: ZId,
|
||||
});
|
||||
|
||||
export const deleteConnectorAction = authenticatedActionClient
|
||||
.schema(ZDeleteConnectorAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteConnectorAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return deleteConnector(parsedInput.connectorId);
|
||||
}
|
||||
);
|
||||
|
||||
// Create Formbricks mappings for a connector
|
||||
const ZCreateFormbricksMappingsAction = z.object({
|
||||
connectorId: ZId,
|
||||
mappings: z.array(ZConnectorFormbricksMappingCreateInput),
|
||||
});
|
||||
|
||||
export const createFormbricksMappingsAction = authenticatedActionClient
|
||||
.schema(ZCreateFormbricksMappingsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateFormbricksMappingsAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return createFormbricksMappings(parsedInput.connectorId, parsedInput.mappings);
|
||||
}
|
||||
);
|
||||
|
||||
// Sync (replace) Formbricks mappings for a connector
|
||||
const ZSyncFormbricksMappingsAction = z.object({
|
||||
connectorId: ZId,
|
||||
mappings: z.array(ZConnectorFormbricksMappingCreateInput),
|
||||
});
|
||||
|
||||
export const syncFormbricksMappingsAction = authenticatedActionClient
|
||||
.schema(ZSyncFormbricksMappingsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZSyncFormbricksMappingsAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return syncFormbricksMappings(parsedInput.connectorId, parsedInput.mappings);
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a Formbricks mapping
|
||||
const ZDeleteFormbricksMappingAction = z.object({
|
||||
mappingId: ZId,
|
||||
connectorId: ZId, // For authorization
|
||||
});
|
||||
|
||||
export const deleteFormbricksMappingAction = authenticatedActionClient
|
||||
.schema(ZDeleteFormbricksMappingAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteFormbricksMappingAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return deleteFormbricksMapping(parsedInput.mappingId);
|
||||
}
|
||||
);
|
||||
|
||||
// Create field mappings for a connector (webhook, csv, etc.)
|
||||
const ZCreateFieldMappingsAction = z.object({
|
||||
connectorId: ZId,
|
||||
mappings: z.array(ZConnectorFieldMappingCreateInput),
|
||||
});
|
||||
|
||||
export const createFieldMappingsAction = authenticatedActionClient
|
||||
.schema(ZCreateFieldMappingsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateFieldMappingsAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return createFieldMappings(parsedInput.connectorId, parsedInput.mappings);
|
||||
}
|
||||
);
|
||||
|
||||
// Sync (replace) field mappings for a connector
|
||||
const ZSyncFieldMappingsAction = z.object({
|
||||
connectorId: ZId,
|
||||
mappings: z.array(ZConnectorFieldMappingCreateInput),
|
||||
});
|
||||
|
||||
export const syncFieldMappingsAction = authenticatedActionClient
|
||||
.schema(ZSyncFieldMappingsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZSyncFieldMappingsAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return syncFieldMappings(parsedInput.connectorId, parsedInput.mappings);
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a field mapping
|
||||
const ZDeleteFieldMappingAction = z.object({
|
||||
mappingId: ZId,
|
||||
connectorId: ZId, // For authorization
|
||||
});
|
||||
|
||||
export const deleteFieldMappingAction = authenticatedActionClient
|
||||
.schema(ZDeleteFieldMappingAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteFieldMappingAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return deleteFieldMapping(parsedInput.mappingId);
|
||||
}
|
||||
);
|
||||
315
apps/web/lib/connector/hub-client.ts
Normal file
315
apps/web/lib/connector/hub-client.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
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 || "";
|
||||
|
||||
// Hub field types (from OpenAPI spec)
|
||||
export type THubFieldType =
|
||||
| "text"
|
||||
| "categorical"
|
||||
| "nps"
|
||||
| "csat"
|
||||
| "ces"
|
||||
| "rating"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date";
|
||||
|
||||
// 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
|
||||
tenant_id?: string;
|
||||
response_id?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
field_label?: string;
|
||||
value_text?: string;
|
||||
value_number?: number;
|
||||
value_boolean?: boolean;
|
||||
value_date?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
language?: string;
|
||||
user_identifier?: string;
|
||||
}
|
||||
|
||||
// FeedbackRecord data (response from Hub)
|
||||
export interface TFeedbackRecordData {
|
||||
id: string;
|
||||
collected_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
source_type: string;
|
||||
field_id: string;
|
||||
field_type: 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;
|
||||
value_date?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
language?: string;
|
||||
user_identifier?: string;
|
||||
}
|
||||
|
||||
// List FeedbackRecords response
|
||||
export interface TListFeedbackRecordsResponse {
|
||||
data: TFeedbackRecordData[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// Update FeedbackRecord input
|
||||
export interface TUpdateFeedbackRecordInput {
|
||||
value_text?: string;
|
||||
value_number?: number;
|
||||
value_boolean?: boolean;
|
||||
value_date?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
language?: string;
|
||||
user_identifier?: string;
|
||||
}
|
||||
|
||||
// List FeedbackRecords filters
|
||||
export interface TListFeedbackRecordsFilters {
|
||||
tenant_id?: string;
|
||||
response_id?: string;
|
||||
source_type?: string;
|
||||
source_id?: string;
|
||||
field_id?: string;
|
||||
field_type?: string;
|
||||
user_identifier?: string;
|
||||
since?: string; // ISO 8601
|
||||
until?: string; // ISO 8601
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Error response from Hub
|
||||
export interface THubErrorResponse {
|
||||
type?: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail: string;
|
||||
instance?: string;
|
||||
errors?: Array<{
|
||||
location?: string;
|
||||
message?: string;
|
||||
value?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Hub API Error class
|
||||
export class HubApiError extends Error {
|
||||
status: number;
|
||||
detail: string;
|
||||
errors?: THubErrorResponse["errors"];
|
||||
|
||||
constructor(response: THubErrorResponse) {
|
||||
super(response.detail || response.title);
|
||||
this.name = "HubApiError";
|
||||
this.status = response.status;
|
||||
this.detail = response.detail;
|
||||
this.errors = response.errors;
|
||||
}
|
||||
}
|
||||
|
||||
// Make authenticated request to Hub API
|
||||
async function hubFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: T | null; error: HubApiError | null }> {
|
||||
const url = `${HUB_API_URL}${path}`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...(HUB_API_KEY && { Authorization: `Bearer ${HUB_API_KEY}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle no content response (e.g., DELETE)
|
||||
if (response.status === 204) {
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error response
|
||||
if (contentType?.includes("application/problem+json") || contentType?.includes("application/json")) {
|
||||
const errorBody = (await response.json()) as THubErrorResponse;
|
||||
return { data: null, error: new HubApiError(errorBody) };
|
||||
}
|
||||
|
||||
// Fallback for non-JSON errors
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
data: null,
|
||||
error: new HubApiError({
|
||||
title: "Error",
|
||||
status: response.status,
|
||||
detail: errorText || `HTTP ${response.status}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
if (contentType?.includes("application/json")) {
|
||||
const data = (await response.json()) as T;
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
return { data: null, error: null };
|
||||
} catch (error) {
|
||||
logger.error("Hub API request failed", { url, error });
|
||||
return {
|
||||
data: null,
|
||||
error: new HubApiError({
|
||||
title: "Network Error",
|
||||
status: 0,
|
||||
detail: error instanceof Error ? error.message : "Failed to connect to Hub API",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new FeedbackRecord in the Hub
|
||||
*/
|
||||
export async function createFeedbackRecord(
|
||||
input: TCreateFeedbackRecordInput
|
||||
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
|
||||
return hubFetch<TFeedbackRecordData>("/v1/feedback-records", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple FeedbackRecords in the Hub (batch)
|
||||
*/
|
||||
export async function createFeedbackRecordsBatch(
|
||||
inputs: TCreateFeedbackRecordInput[]
|
||||
): Promise<{ results: Array<{ data: TFeedbackRecordData | null; error: HubApiError | null }> }> {
|
||||
// Hub doesn't have a batch endpoint, so we'll do parallel requests
|
||||
// In production, you might want to implement rate limiting or chunking
|
||||
const results = await Promise.all(inputs.map((input) => createFeedbackRecord(input)));
|
||||
return { results };
|
||||
}
|
||||
|
||||
/**
|
||||
* List FeedbackRecords from the Hub with optional filters
|
||||
*/
|
||||
export async function listFeedbackRecords(
|
||||
filters: TListFeedbackRecordsFilters = {}
|
||||
): Promise<{ data: TListFeedbackRecordsResponse | null; error: HubApiError | null }> {
|
||||
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_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);
|
||||
if (filters.until) searchParams.set("until", filters.until);
|
||||
if (filters.limit !== undefined) searchParams.set("limit", String(filters.limit));
|
||||
if (filters.offset !== undefined) searchParams.set("offset", String(filters.offset));
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const path = queryString ? `/v1/feedback-records?${queryString}` : "/v1/feedback-records";
|
||||
|
||||
return hubFetch<TListFeedbackRecordsResponse>(path, { method: "GET" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single FeedbackRecord from the Hub by ID
|
||||
*/
|
||||
export async function getFeedbackRecord(
|
||||
id: string
|
||||
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
|
||||
return hubFetch<TFeedbackRecordData>(`/v1/feedback-records/${id}`, { method: "GET" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a FeedbackRecord in the Hub
|
||||
*/
|
||||
export async function updateFeedbackRecord(
|
||||
id: string,
|
||||
input: TUpdateFeedbackRecordInput
|
||||
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
|
||||
return hubFetch<TFeedbackRecordData>(`/v1/feedback-records/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a FeedbackRecord from the Hub
|
||||
*/
|
||||
export async function deleteFeedbackRecord(id: string): Promise<{ error: HubApiError | null }> {
|
||||
const result = await hubFetch<null>(`/v1/feedback-records/${id}`, { method: "DELETE" });
|
||||
return { error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete FeedbackRecords by user identifier (GDPR compliance)
|
||||
*/
|
||||
export async function bulkDeleteFeedbackRecordsByUser(
|
||||
userIdentifier: string,
|
||||
tenantId?: string
|
||||
): Promise<{ data: { deleted_count: number; message: string } | null; error: HubApiError | null }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("user_identifier", userIdentifier);
|
||||
if (tenantId) searchParams.set("tenant_id", tenantId);
|
||||
|
||||
return hubFetch<{ deleted_count: number; message: string }>(
|
||||
`/v1/feedback-records?${searchParams.toString()}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Hub API health
|
||||
*/
|
||||
export async function checkHubHealth(): Promise<{ healthy: boolean; error: HubApiError | null }> {
|
||||
try {
|
||||
const response = await fetch(`${HUB_API_URL}/health`);
|
||||
if (response.ok) {
|
||||
return { healthy: true, error: null };
|
||||
}
|
||||
return {
|
||||
healthy: false,
|
||||
error: new HubApiError({
|
||||
title: "Health Check Failed",
|
||||
status: response.status,
|
||||
detail: "Hub API health check failed",
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
healthy: false,
|
||||
error: new HubApiError({
|
||||
title: "Network Error",
|
||||
status: 0,
|
||||
detail: error instanceof Error ? error.message : "Failed to connect to Hub API",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
138
apps/web/lib/connector/pipeline-handler.ts
Normal file
138
apps/web/lib/connector/pipeline-handler.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { createFeedbackRecordsBatch } from "./hub-client";
|
||||
import { getConnectorsBySurveyId, updateConnector } from "./service";
|
||||
import { transformResponseToFeedbackRecords } from "./transform";
|
||||
|
||||
/**
|
||||
* Handle connector pipeline for a survey response
|
||||
*
|
||||
* This function is called from the pipeline when a response is created/finished.
|
||||
* It looks up active connectors for the survey and sends the response data to the Hub.
|
||||
*
|
||||
* @param response - The survey response
|
||||
* @param survey - The survey
|
||||
* @param environmentId - The environment ID (used as tenant_id)
|
||||
*/
|
||||
export async function handleConnectorPipeline(
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
environmentId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get all active Formbricks connectors for this survey
|
||||
const connectors = await getConnectorsBySurveyId(survey.id);
|
||||
|
||||
if (connectors.length === 0) {
|
||||
// No connectors configured for this survey
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each connector
|
||||
for (const connector of connectors) {
|
||||
try {
|
||||
// Transform response to FeedbackRecords using the connector's mappings
|
||||
const feedbackRecords = transformResponseToFeedbackRecords(
|
||||
response,
|
||||
survey,
|
||||
connector.formbricksMappings,
|
||||
environmentId // Use environment ID as tenant_id
|
||||
);
|
||||
|
||||
if (feedbackRecords.length === 0) {
|
||||
// No mapped elements had values in this response
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send to Hub API
|
||||
const { results } = await createFeedbackRecordsBatch(feedbackRecords);
|
||||
|
||||
// Count successes and failures
|
||||
const successes = results.filter((r) => r.data !== null).length;
|
||||
const failures = results.filter((r) => r.error !== null).length;
|
||||
|
||||
if (failures > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
connectorId: connector.id,
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
successes,
|
||||
failures,
|
||||
},
|
||||
`Connector pipeline: ${failures}/${feedbackRecords.length} FeedbackRecords failed to send`
|
||||
);
|
||||
|
||||
// Log the specific errors
|
||||
results.forEach((result, index) => {
|
||||
if (result.error) {
|
||||
logger.error(
|
||||
{
|
||||
connectorId: connector.id,
|
||||
feedbackRecordIndex: index,
|
||||
error: {
|
||||
status: result.error.status,
|
||||
message: result.error.message,
|
||||
detail: result.error.detail,
|
||||
},
|
||||
},
|
||||
"Failed to create FeedbackRecord in Hub"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Update connector with error message if all failed
|
||||
if (successes === 0) {
|
||||
await updateConnector(connector.id, {
|
||||
errorMessage: `Failed to send FeedbackRecords to Hub: ${results[0].error?.message || "Unknown error"}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
{
|
||||
connectorId: connector.id,
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
feedbackRecordsCreated: successes,
|
||||
},
|
||||
`Connector pipeline: Successfully sent ${successes} FeedbackRecords to Hub`
|
||||
);
|
||||
|
||||
// Clear any previous error and update lastSyncAt
|
||||
await updateConnector(connector.id, {
|
||||
errorMessage: null,
|
||||
lastSyncAt: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
connectorId: connector.id,
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
"Connector pipeline: Failed to process connector"
|
||||
);
|
||||
|
||||
// Update connector with error
|
||||
await updateConnector(connector.id, {
|
||||
status: "error",
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't throw - we don't want to break the main pipeline
|
||||
logger.error(
|
||||
{
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
"Connector pipeline: Failed to handle connectors"
|
||||
);
|
||||
}
|
||||
}
|
||||
663
apps/web/lib/connector/service.ts
Normal file
663
apps/web/lib/connector/service.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import {
|
||||
TConnector,
|
||||
TConnectorCreateInput,
|
||||
TConnectorFieldMapping,
|
||||
TConnectorFieldMappingCreateInput,
|
||||
TConnectorFormbricksMapping,
|
||||
TConnectorFormbricksMappingCreateInput,
|
||||
TConnectorUpdateInput,
|
||||
TConnectorWithMappings,
|
||||
TFormbricksConnector,
|
||||
ZConnectorCreateInput,
|
||||
ZConnectorFieldMappingCreateInput,
|
||||
ZConnectorFormbricksMappingCreateInput,
|
||||
ZConnectorUpdateInput,
|
||||
} from "@formbricks/types/connector";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
// Select object for Connector with Formbricks mappings
|
||||
const selectConnectorWithFormbricksMappings = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
type: true,
|
||||
status: true,
|
||||
environmentId: true,
|
||||
config: true,
|
||||
lastSyncAt: true,
|
||||
errorMessage: true,
|
||||
formbricksMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
connectorId: true,
|
||||
surveyId: true,
|
||||
elementId: true,
|
||||
hubFieldType: true,
|
||||
customFieldLabel: true,
|
||||
survey: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fieldMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
connectorId: true,
|
||||
sourceFieldId: true,
|
||||
targetFieldId: true,
|
||||
staticValue: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ConnectorSelect;
|
||||
|
||||
// Select object for Connector without mappings
|
||||
const selectConnector = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
type: true,
|
||||
status: true,
|
||||
environmentId: true,
|
||||
config: true,
|
||||
lastSyncAt: true,
|
||||
errorMessage: true,
|
||||
} satisfies Prisma.ConnectorSelect;
|
||||
|
||||
/**
|
||||
* Get all connectors for an environment
|
||||
*/
|
||||
export const getConnectors = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<TConnector[]> => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const connectors = await prisma.connector.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
select: selectConnector,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
|
||||
return connectors as TConnector[];
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all connectors for an environment with their mappings
|
||||
*/
|
||||
export const getConnectorsWithMappings = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<TConnectorWithMappings[]> => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const connectors = await prisma.connector.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
select: selectConnectorWithFormbricksMappings,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
|
||||
return connectors as unknown as TConnectorWithMappings[];
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a single connector by ID
|
||||
*/
|
||||
export const getConnector = reactCache(async (connectorId: string): Promise<TConnector | null> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: {
|
||||
id: connectorId,
|
||||
},
|
||||
select: selectConnector,
|
||||
});
|
||||
|
||||
return connector as TConnector | null;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single connector by ID with its mappings
|
||||
*/
|
||||
export const getConnectorWithMappings = reactCache(
|
||||
async (connectorId: string): Promise<TConnectorWithMappings | null> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: {
|
||||
id: connectorId,
|
||||
},
|
||||
select: selectConnectorWithFormbricksMappings,
|
||||
});
|
||||
|
||||
return connector as unknown as TConnectorWithMappings | null;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all Formbricks connectors that have mappings for a specific survey
|
||||
*/
|
||||
export const getConnectorsBySurveyId = reactCache(
|
||||
async (surveyId: string): Promise<TFormbricksConnector[]> => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
|
||||
try {
|
||||
const connectors = await prisma.connector.findMany({
|
||||
where: {
|
||||
type: "formbricks",
|
||||
status: "active",
|
||||
formbricksMappings: {
|
||||
some: {
|
||||
surveyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: selectConnectorWithFormbricksMappings,
|
||||
});
|
||||
|
||||
return connectors as unknown as TFormbricksConnector[];
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export enum ConnectorError {
|
||||
CONNECTOR_NOT_FOUND = "CONNECTOR_NOT_FOUND",
|
||||
DUPLICATE_MAPPING = "DUPLICATE_MAPPING",
|
||||
UNEXPECTED_ERROR = "UNEXPECTED_ERROR",
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new connector
|
||||
*/
|
||||
export const createConnector = async (
|
||||
environmentId: string,
|
||||
data: TConnectorCreateInput
|
||||
): Promise<Result<TConnector, { code: ConnectorError; message: string }>> => {
|
||||
validateInputs([environmentId, ZId], [data, ZConnectorCreateInput]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
environmentId,
|
||||
config: data.config ?? Prisma.JsonNull,
|
||||
},
|
||||
select: selectConnector,
|
||||
});
|
||||
|
||||
return ok(connector as TConnector);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a connector
|
||||
*/
|
||||
export const updateConnector = async (
|
||||
connectorId: string,
|
||||
data: TConnectorUpdateInput
|
||||
): Promise<Result<TConnector, { code: ConnectorError; message: string }>> => {
|
||||
validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.update({
|
||||
where: {
|
||||
id: connectorId,
|
||||
},
|
||||
data: {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
config: data.config ?? undefined,
|
||||
errorMessage: data.errorMessage,
|
||||
lastSyncAt: data.lastSyncAt,
|
||||
},
|
||||
select: selectConnector,
|
||||
});
|
||||
|
||||
return ok(connector as TConnector);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.RecordNotFound) {
|
||||
return err({
|
||||
code: ConnectorError.CONNECTOR_NOT_FOUND,
|
||||
message: "Connector not found",
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a connector
|
||||
*/
|
||||
export const deleteConnector = async (
|
||||
connectorId: string
|
||||
): Promise<Result<TConnector, { code: ConnectorError; message: string }>> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.delete({
|
||||
where: {
|
||||
id: connectorId,
|
||||
},
|
||||
select: selectConnector,
|
||||
});
|
||||
|
||||
return ok(connector as TConnector);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.RecordNotFound) {
|
||||
return err({
|
||||
code: ConnectorError.CONNECTOR_NOT_FOUND,
|
||||
message: "Connector not found",
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Formbricks element mappings for a connector
|
||||
*/
|
||||
export const createFormbricksMappings = async (
|
||||
connectorId: string,
|
||||
mappings: TConnectorFormbricksMappingCreateInput[]
|
||||
): Promise<Result<TConnectorFormbricksMapping[], { code: ConnectorError; message: string }>> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
mappings.forEach((mapping) => validateInputs([mapping, ZConnectorFormbricksMappingCreateInput]));
|
||||
|
||||
try {
|
||||
const createdMappings = await prisma.$transaction(
|
||||
mappings.map((mapping) =>
|
||||
prisma.connectorFormbricksMapping.create({
|
||||
data: {
|
||||
connectorId,
|
||||
surveyId: mapping.surveyId,
|
||||
elementId: mapping.elementId,
|
||||
hubFieldType: mapping.hubFieldType,
|
||||
customFieldLabel: mapping.customFieldLabel,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return ok(createdMappings as TConnectorFormbricksMapping[]);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
return err({
|
||||
code: ConnectorError.DUPLICATE_MAPPING,
|
||||
message: "A mapping for this survey element already exists",
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync Formbricks mappings - replaces all existing mappings with new ones
|
||||
*/
|
||||
export const syncFormbricksMappings = async (
|
||||
connectorId: string,
|
||||
mappings: TConnectorFormbricksMappingCreateInput[]
|
||||
): Promise<Result<TConnectorFormbricksMapping[], { code: ConnectorError; message: string }>> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
mappings.forEach((mapping) => validateInputs([mapping, ZConnectorFormbricksMappingCreateInput]));
|
||||
|
||||
try {
|
||||
// Delete all existing mappings and create new ones in a transaction
|
||||
const createdMappings = await prisma.$transaction(async (tx) => {
|
||||
// Delete existing mappings
|
||||
await tx.connectorFormbricksMapping.deleteMany({
|
||||
where: {
|
||||
connectorId,
|
||||
},
|
||||
});
|
||||
|
||||
// Create new mappings
|
||||
const newMappings = await Promise.all(
|
||||
mappings.map((mapping) =>
|
||||
tx.connectorFormbricksMapping.create({
|
||||
data: {
|
||||
connectorId,
|
||||
surveyId: mapping.surveyId,
|
||||
elementId: mapping.elementId,
|
||||
hubFieldType: mapping.hubFieldType,
|
||||
customFieldLabel: mapping.customFieldLabel,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return newMappings;
|
||||
});
|
||||
|
||||
return ok(createdMappings as TConnectorFormbricksMapping[]);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a Formbricks mapping
|
||||
*/
|
||||
export const deleteFormbricksMapping = async (
|
||||
mappingId: string
|
||||
): Promise<Result<TConnectorFormbricksMapping, { code: ConnectorError; message: string }>> => {
|
||||
validateInputs([mappingId, ZId]);
|
||||
|
||||
try {
|
||||
const mapping = await prisma.connectorFormbricksMapping.delete({
|
||||
where: {
|
||||
id: mappingId,
|
||||
},
|
||||
});
|
||||
|
||||
return ok(mapping as TConnectorFormbricksMapping);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.RecordNotFound) {
|
||||
return err({
|
||||
code: ConnectorError.CONNECTOR_NOT_FOUND,
|
||||
message: "Mapping not found",
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create field mappings for a connector (webhook, csv, email, slack)
|
||||
*/
|
||||
export const createFieldMappings = async (
|
||||
connectorId: string,
|
||||
mappings: TConnectorFieldMappingCreateInput[]
|
||||
): Promise<Result<TConnectorFieldMapping[], { code: ConnectorError; message: string }>> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
mappings.forEach((mapping) => validateInputs([mapping, ZConnectorFieldMappingCreateInput]));
|
||||
|
||||
try {
|
||||
const createdMappings = await prisma.$transaction(
|
||||
mappings.map((mapping) =>
|
||||
prisma.connectorFieldMapping.create({
|
||||
data: {
|
||||
connectorId,
|
||||
sourceFieldId: mapping.sourceFieldId,
|
||||
targetFieldId: mapping.targetFieldId,
|
||||
staticValue: mapping.staticValue,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return ok(createdMappings as TConnectorFieldMapping[]);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
return err({
|
||||
code: ConnectorError.DUPLICATE_MAPPING,
|
||||
message: "A mapping for this field already exists",
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync field mappings - replaces all existing field mappings with new ones
|
||||
*/
|
||||
export const syncFieldMappings = async (
|
||||
connectorId: string,
|
||||
mappings: TConnectorFieldMappingCreateInput[]
|
||||
): Promise<Result<TConnectorFieldMapping[], { code: ConnectorError; message: string }>> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
mappings.forEach((mapping) => validateInputs([mapping, ZConnectorFieldMappingCreateInput]));
|
||||
|
||||
try {
|
||||
// Delete all existing mappings and create new ones in a transaction
|
||||
const createdMappings = await prisma.$transaction(async (tx) => {
|
||||
// Delete existing mappings
|
||||
await tx.connectorFieldMapping.deleteMany({
|
||||
where: {
|
||||
connectorId,
|
||||
},
|
||||
});
|
||||
|
||||
// Create new mappings
|
||||
const newMappings = await Promise.all(
|
||||
mappings.map((mapping) =>
|
||||
tx.connectorFieldMapping.create({
|
||||
data: {
|
||||
connectorId,
|
||||
sourceFieldId: mapping.sourceFieldId,
|
||||
targetFieldId: mapping.targetFieldId,
|
||||
staticValue: mapping.staticValue,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return newMappings;
|
||||
});
|
||||
|
||||
return ok(createdMappings as TConnectorFieldMapping[]);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a field mapping
|
||||
*/
|
||||
export const deleteFieldMapping = async (
|
||||
mappingId: string
|
||||
): Promise<Result<TConnectorFieldMapping, { code: ConnectorError; message: string }>> => {
|
||||
validateInputs([mappingId, ZId]);
|
||||
|
||||
try {
|
||||
const mapping = await prisma.connectorFieldMapping.delete({
|
||||
where: {
|
||||
id: mappingId,
|
||||
},
|
||||
});
|
||||
|
||||
return ok(mapping as TConnectorFieldMapping);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.RecordNotFound) {
|
||||
return err({
|
||||
code: ConnectorError.CONNECTOR_NOT_FOUND,
|
||||
message: "Mapping not found",
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return err({
|
||||
code: ConnectorError.UNEXPECTED_ERROR,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get Formbricks mappings for a connector
|
||||
*/
|
||||
export const getFormbricksMappings = reactCache(
|
||||
async (connectorId: string): Promise<TConnectorFormbricksMapping[]> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
|
||||
try {
|
||||
const mappings = await prisma.connectorFormbricksMapping.findMany({
|
||||
where: {
|
||||
connectorId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
return mappings as TConnectorFormbricksMapping[];
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get field mappings for a connector
|
||||
*/
|
||||
export const getFieldMappings = reactCache(async (connectorId: string): Promise<TConnectorFieldMapping[]> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
|
||||
try {
|
||||
const mappings = await prisma.connectorFieldMapping.findMany({
|
||||
where: {
|
||||
connectorId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
return mappings as TConnectorFieldMapping[];
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
375
apps/web/lib/connector/transform.ts
Normal file
375
apps/web/lib/connector/transform.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import "server-only";
|
||||
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TCreateFeedbackRecordInput, THubFieldType as THubClientFieldType } from "./hub-client";
|
||||
|
||||
// Response data value types
|
||||
type TResponseValue = string | number | string[] | Record<string, string> | undefined;
|
||||
|
||||
/**
|
||||
* Get the headline of an element from a survey
|
||||
*/
|
||||
function getElementHeadline(survey: TSurvey, elementId: string): string {
|
||||
// Try to find in blocks first
|
||||
if (survey.blocks && survey.blocks.length > 0) {
|
||||
for (const block of survey.blocks) {
|
||||
if (block.elements) {
|
||||
for (const element of block.elements) {
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy questions
|
||||
if (survey.questions && Array.isArray(survey.questions)) {
|
||||
for (const question of survey.questions as Array<{
|
||||
id: string;
|
||||
headline?: string | { default?: 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Untitled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the value from a response for a specific element
|
||||
*/
|
||||
function extractResponseValue(responseData: TResponse["data"], elementId: string): TResponseValue {
|
||||
if (!responseData || typeof responseData !== "object") return undefined;
|
||||
return (responseData as Record<string, TResponseValue>)[elementId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a response value to the appropriate Hub value fields
|
||||
*/
|
||||
function convertValueToHubFields(
|
||||
value: TResponseValue,
|
||||
hubFieldType: THubFieldType
|
||||
): Partial<Pick<TCreateFeedbackRecordInput, "value_text" | "value_number" | "value_boolean" | "value_date">> {
|
||||
if (value === undefined || value === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (hubFieldType) {
|
||||
case "text":
|
||||
// Text values - could be string or array of strings
|
||||
if (typeof value === "string") {
|
||||
return { value_text: value };
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return { value_text: value.join(", ") };
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
// Handle address-like objects
|
||||
return { value_text: JSON.stringify(value) };
|
||||
}
|
||||
return { value_text: String(value) };
|
||||
|
||||
case "number":
|
||||
case "rating":
|
||||
case "nps":
|
||||
case "csat":
|
||||
case "ces":
|
||||
// Numeric values
|
||||
if (typeof value === "number") {
|
||||
return { value_number: value };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseFloat(value);
|
||||
if (!isNaN(parsed)) {
|
||||
return { value_number: parsed };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
|
||||
case "boolean":
|
||||
// Boolean values
|
||||
if (typeof value === "boolean") {
|
||||
return { value_boolean: value };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return { value_boolean: value.toLowerCase() === "true" || value === "1" };
|
||||
}
|
||||
return {};
|
||||
|
||||
case "date":
|
||||
// Date values
|
||||
if (typeof value === "string") {
|
||||
return { value_date: value };
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return { value_date: value.toISOString() };
|
||||
}
|
||||
return {};
|
||||
|
||||
case "categorical":
|
||||
// Categorical values (like multiple choice)
|
||||
if (typeof value === "string") {
|
||||
return { value_text: value };
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return { value_text: value.join(", ") };
|
||||
}
|
||||
return { value_text: String(value) };
|
||||
|
||||
default:
|
||||
// Default to text
|
||||
if (typeof value === "string") {
|
||||
return { value_text: value };
|
||||
}
|
||||
return { value_text: String(value) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a Formbricks survey response into Hub FeedbackRecord payloads
|
||||
*
|
||||
* @param response - The Formbricks response
|
||||
* @param survey - The survey the response belongs to
|
||||
* @param mappings - The connector mappings for this survey
|
||||
* @param tenantId - Optional tenant ID (usually environment or organization ID)
|
||||
* @returns Array of FeedbackRecord payloads to send to the Hub
|
||||
*/
|
||||
export function transformResponseToFeedbackRecords(
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
mappings: TConnectorFormbricksMapping[],
|
||||
tenantId?: string
|
||||
): TCreateFeedbackRecordInput[] {
|
||||
const feedbackRecords: TCreateFeedbackRecordInput[] = [];
|
||||
|
||||
// Get response data
|
||||
const responseData = response.data;
|
||||
if (!responseData) {
|
||||
return feedbackRecords;
|
||||
}
|
||||
|
||||
// Filter mappings to only those for this survey
|
||||
const surveyMappings = mappings.filter((m) => m.surveyId === survey.id);
|
||||
|
||||
// For each mapped element, create a FeedbackRecord
|
||||
for (const mapping of surveyMappings) {
|
||||
const value = extractResponseValue(responseData, mapping.elementId);
|
||||
|
||||
// Skip if no value for this element
|
||||
if (value === undefined || value === null || value === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get element headline (or use custom field label)
|
||||
const fieldLabel = mapping.customFieldLabel || getElementHeadline(survey, mapping.elementId);
|
||||
|
||||
// Convert value to appropriate Hub fields
|
||||
const valueFields = convertValueToHubFields(value, mapping.hubFieldType as THubFieldType);
|
||||
|
||||
// Create the FeedbackRecord payload
|
||||
const feedbackRecord: TCreateFeedbackRecordInput = {
|
||||
collected_at: response.createdAt.toISOString(),
|
||||
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
|
||||
if (tenantId) {
|
||||
feedbackRecord.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
// Add user identifier if available
|
||||
if (response.contactId) {
|
||||
feedbackRecord.user_identifier = response.contactId;
|
||||
}
|
||||
|
||||
feedbackRecords.push(feedbackRecord);
|
||||
}
|
||||
|
||||
return feedbackRecords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a webhook payload to a FeedbackRecord using field mappings
|
||||
*
|
||||
* @param payload - The incoming webhook payload
|
||||
* @param mappings - The field mappings for this connector
|
||||
* @returns FeedbackRecord payload to send to the Hub
|
||||
*/
|
||||
export function transformWebhookPayloadToFeedbackRecord(
|
||||
payload: Record<string, unknown>,
|
||||
mappings: Array<{
|
||||
sourceFieldId: string;
|
||||
targetFieldId: string;
|
||||
staticValue?: string | null;
|
||||
}>
|
||||
): TCreateFeedbackRecordInput {
|
||||
const feedbackRecord: Record<string, unknown> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
let value: unknown;
|
||||
|
||||
if (mapping.staticValue) {
|
||||
// Use static value
|
||||
value = mapping.staticValue;
|
||||
|
||||
// Handle special static values
|
||||
if (value === "$now") {
|
||||
value = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
// Get value from payload using dot notation path
|
||||
value = getNestedValue(payload, mapping.sourceFieldId);
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
feedbackRecord[mapping.targetFieldId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure required fields have defaults
|
||||
if (!feedbackRecord.source_type) {
|
||||
feedbackRecord.source_type = "webhook";
|
||||
}
|
||||
if (!feedbackRecord.collected_at) {
|
||||
feedbackRecord.collected_at = new Date().toISOString();
|
||||
}
|
||||
if (!feedbackRecord.field_type) {
|
||||
feedbackRecord.field_type = "text";
|
||||
}
|
||||
|
||||
return feedbackRecord as TCreateFeedbackRecordInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a CSV row to a FeedbackRecord using field mappings
|
||||
*
|
||||
* @param row - The CSV row as an object (column name -> value)
|
||||
* @param mappings - The field mappings for this connector
|
||||
* @returns FeedbackRecord payload to send to the Hub
|
||||
*/
|
||||
export function transformCSVRowToFeedbackRecord(
|
||||
row: Record<string, string>,
|
||||
mappings: Array<{
|
||||
sourceFieldId: string;
|
||||
targetFieldId: string;
|
||||
staticValue?: string | null;
|
||||
}>
|
||||
): TCreateFeedbackRecordInput {
|
||||
const feedbackRecord: Record<string, unknown> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
let value: unknown;
|
||||
|
||||
if (mapping.staticValue) {
|
||||
// Use static value
|
||||
value = mapping.staticValue;
|
||||
|
||||
// Handle special static values
|
||||
if (value === "$now") {
|
||||
value = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
// Get value from CSV row
|
||||
value = row[mapping.sourceFieldId];
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
// Try to convert to appropriate type based on target field
|
||||
if (mapping.targetFieldId === "value_number") {
|
||||
const parsed = parseFloat(String(value));
|
||||
if (!isNaN(parsed)) {
|
||||
feedbackRecord[mapping.targetFieldId] = parsed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (mapping.targetFieldId === "value_boolean") {
|
||||
feedbackRecord[mapping.targetFieldId] =
|
||||
String(value).toLowerCase() === "true" || String(value) === "1";
|
||||
continue;
|
||||
}
|
||||
|
||||
feedbackRecord[mapping.targetFieldId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure required fields have defaults
|
||||
if (!feedbackRecord.source_type) {
|
||||
feedbackRecord.source_type = "csv";
|
||||
}
|
||||
if (!feedbackRecord.collected_at) {
|
||||
feedbackRecord.collected_at = new Date().toISOString();
|
||||
}
|
||||
if (!feedbackRecord.field_type) {
|
||||
feedbackRecord.field_type = "text";
|
||||
}
|
||||
|
||||
return feedbackRecord as TCreateFeedbackRecordInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a nested value from an object using dot notation and array brackets
|
||||
* e.g., getNestedValue({user: {id: "123"}}, "user.id") => "123"
|
||||
* e.g., getNestedValue({items: [{name: "a"}]}, "items[0].name") => "a"
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
let current: unknown = obj;
|
||||
|
||||
// Split by dots, but we need to handle array notation within each segment
|
||||
const segments = path.split(".");
|
||||
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if segment contains array notation like "answers[0]" or just "[0]"
|
||||
const arrayMatch = segment.match(/^([^\[]*)\[(\d+)\]$/);
|
||||
|
||||
if (arrayMatch) {
|
||||
const [, propertyName, indexStr] = arrayMatch;
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
// If there's a property name before the bracket, access it first
|
||||
if (propertyName) {
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[propertyName];
|
||||
}
|
||||
|
||||
// Now access the array index
|
||||
if (!Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[index];
|
||||
} else {
|
||||
// Regular property access
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
155
apps/web/lib/connector/webhook-listener-store.ts
Normal file
155
apps/web/lib/connector/webhook-listener-store.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import "server-only";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
// Session TTL in milliseconds (5 minutes)
|
||||
const SESSION_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
// Maximum payload size in bytes (100KB)
|
||||
const MAX_PAYLOAD_SIZE = 100 * 1024;
|
||||
|
||||
// Cleanup interval in milliseconds (1 minute)
|
||||
const CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
interface WebhookSession {
|
||||
payload: Record<string, unknown>;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
// In-memory store for webhook payloads
|
||||
const sessionStore = new Map<string, WebhookSession>();
|
||||
|
||||
// Track if cleanup interval is running
|
||||
let cleanupIntervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Generate a unique session ID for webhook listening
|
||||
*/
|
||||
export function generateSessionId(): string {
|
||||
return nanoid(21);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a received webhook payload for a session
|
||||
* @returns true if stored successfully, false if payload is too large or session doesn't exist
|
||||
*/
|
||||
export function storePayload(sessionId: string, payload: Record<string, unknown>): boolean {
|
||||
// Check payload size
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
if (payloadStr.length > MAX_PAYLOAD_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionStore.set(sessionId, {
|
||||
payload,
|
||||
receivedAt: Date.now(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the received payload for a session
|
||||
* @param clear - If true, removes the payload after retrieval (default: true)
|
||||
* @returns The payload if found, null otherwise
|
||||
*/
|
||||
export function getPayload(sessionId: string, clear: boolean = true): Record<string, unknown> | null {
|
||||
const session = sessionStore.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if session has expired
|
||||
if (Date.now() - session.receivedAt > SESSION_TTL_MS) {
|
||||
sessionStore.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { payload } = session;
|
||||
|
||||
if (clear) {
|
||||
sessionStore.delete(sessionId);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session exists (for validation)
|
||||
*/
|
||||
export function sessionExists(sessionId: string): boolean {
|
||||
return sessionStore.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new listening session
|
||||
* @returns The session ID
|
||||
*/
|
||||
export function createSession(): string {
|
||||
const sessionId = generateSessionId();
|
||||
// Initialize with empty marker to indicate session is active
|
||||
// This doesn't store a payload yet, just marks the session as valid
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
*/
|
||||
export function deleteSession(sessionId: string): void {
|
||||
sessionStore.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions
|
||||
*/
|
||||
export function cleanupExpiredSessions(): number {
|
||||
const now = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [sessionId, session] of sessionStore.entries()) {
|
||||
if (now - session.receivedAt > SESSION_TTL_MS) {
|
||||
sessionStore.delete(sessionId);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the automatic cleanup interval
|
||||
*/
|
||||
export function startCleanupInterval(): void {
|
||||
if (cleanupIntervalId) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
cleanupIntervalId = setInterval(() => {
|
||||
cleanupExpiredSessions();
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Prevent the interval from keeping the process alive
|
||||
if (cleanupIntervalId.unref) {
|
||||
cleanupIntervalId.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the automatic cleanup interval
|
||||
*/
|
||||
export function stopCleanupInterval(): void {
|
||||
if (cleanupIntervalId) {
|
||||
clearInterval(cleanupIntervalId);
|
||||
cleanupIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current number of active sessions (for debugging/monitoring)
|
||||
*/
|
||||
export function getSessionCount(): number {
|
||||
return sessionStore.size;
|
||||
}
|
||||
|
||||
// Start cleanup on module load
|
||||
startCleanupInterval();
|
||||
@@ -165,20 +165,19 @@ export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
|
||||
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"de-DE",
|
||||
"en-US",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hu-HU",
|
||||
"ja-JP",
|
||||
"nl-NL",
|
||||
"de-DE",
|
||||
"pt-BR",
|
||||
"fr-FR",
|
||||
"nl-NL",
|
||||
"zh-Hant-TW",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
"ru-RU",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
|
||||
@@ -126,12 +126,6 @@ export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[
|
||||
};
|
||||
|
||||
export const appLanguages = [
|
||||
{
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "German",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "en-US",
|
||||
label: {
|
||||
@@ -139,9 +133,15 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"en-US": "German",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -151,27 +151,9 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "hu-HU",
|
||||
code: "zh-Hant-TW",
|
||||
label: {
|
||||
"en-US": "Hungarian",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Japanese",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
"en-US": "Chinese (Traditional)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,15 +169,9 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
"en-US": "Japanese",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -205,9 +181,27 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hant-TW",
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Chinese (Traditional)",
|
||||
"en-US": "Dutch",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user