Compare commits

..

12 Commits

Author SHA1 Message Date
pandeymangg
dc4519f445 adds webhook signing to test event 2026-02-20 17:30:11 +05:30
neila
6e4ef9a099 fix: make pretty URL paths accessible from public domain (#7264)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:40 +00:00
Chowdhury Tafsir Ahmed Siddiki
ebf7d1e3a1 fix: prevent crash in NotificationSwitch via optional chaining (#7268)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:06 +00:00
Dhruwang Jariwala
998162bc48 fix: Google Sheets integration — token expiry & permission error handling (#7282) (#7285) 2026-02-20 08:56:24 +00:00
Anshuman Pandey
4fadc54b4e fix: fixes storage resolution issues (#7310) 2026-02-19 14:03:19 +00:00
Dhruwang Jariwala
f4ac9a8292 fix: always validate only responseData fields in client/management APIs (#7292) (#7296)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 08:56:42 +00:00
Anshuman Pandey
7c8a7606b7 fix: fixes the no segment in draft surveys bug (#7290) 2026-02-19 08:16:18 +00:00
Anshuman Pandey
225217330b fix: adds dataType filter in bc code (#7294) 2026-02-19 07:47:58 +00:00
Dhruwang Jariwala
589c04a530 fix: allow CTA elements to proceed when marked required (#1415) (#7293)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 06:56:03 +00:00
Anshuman Pandey
aa538a3a51 fix: better query in the backwards compatible code (#7288) 2026-02-18 13:00:19 +00:00
Anshuman Pandey
817e108ff5 docs: adds migration docs (#7281)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-02-17 17:01:46 +01:00
Theodór Tómas
33542d0c54 fix: default preview colors (#7277)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-17 11:28:58 +00:00
169 changed files with 2489 additions and 8206 deletions

View File

@@ -229,16 +229,5 @@ REDIS_URL=redis://localhost:6379
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (required for the analytics/dashboard feature)
# URL where the Cube.js instance is running (used by the Next.js app)
# CUBEJS_API_URL=http://localhost:4000
# Must match CUBEJS_API_SECRET set on the Cube.js server (docker-compose.dev.yml)
# CUBEJS_API_SECRET=changeme
# API token sent with each Cube.js request (empty is accepted when CUBEJS_DEV_MODE=true)
# CUBEJS_API_TOKEN=
# Postgres connection used by the Cube.js container (see docker-compose.dev.yml)
# The Cube.js container connects as user "formbricks" to database "hub" on this port
# POSTGRES_PORT=5432
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here

View File

@@ -1,5 +0,0 @@
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
export default function ChartsListLoading() {
return <ChartsListSkeleton />;
}

View File

@@ -1 +0,0 @@
export { ChartsListPage as default } from "@/modules/ee/analysis/charts/pages/charts-list-page";

View File

@@ -1,3 +0,0 @@
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
export default DashboardsListPage;

View File

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

View File

@@ -1,3 +0,0 @@
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
export default DashboardDetailPage;

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -49,8 +49,11 @@ export const NotificationSwitch = ({
];
}
} else {
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
}
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -78,7 +81,7 @@ export const NotificationSwitch = ({
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(

View File

@@ -1,12 +1,49 @@
"use server";
import { z } from "zod";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { ZId } from "@formbricks/types/common";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
} from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
import { getIntegrationByType } from "@/lib/integration/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";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.schema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
if (!integration) {
return { data: false };
}
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
return { data: true };
});
const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(),

View File

@@ -20,6 +20,10 @@ import {
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -118,6 +122,17 @@ export const AddIntegrationModal = ({
resetForm();
}, [selectedIntegration, surveys]);
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
};
const linkSheet = async () => {
try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
@@ -129,6 +144,7 @@ export const AddIntegrationModal = ({
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration,
@@ -137,13 +153,11 @@ export const AddIntegrationModal = ({
});
if (!spreadsheetNameResponse?.data) {
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
throw new Error(errorMessage);
showErrorMessageToast(spreadsheetNameResponse);
return;
}
const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId;
integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id;

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
@@ -8,9 +8,11 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal";
@@ -35,10 +37,23 @@ export const GoogleSheetWrapper = ({
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
if (url) {
@@ -64,6 +79,8 @@ export const GoogleSheetWrapper = ({
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
showReconnectButton={showReconnectButton}
handleGoogleAuthorization={handleGoogleAuthorization}
locale={locale}
/>
</>

View File

@@ -1,6 +1,6 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,15 +12,19 @@ import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
showReconnectButton: boolean;
handleGoogleAuthorization: () => void;
locale: TUserLocale;
}
@@ -29,6 +33,8 @@ export const ManageIntegration = ({
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
showReconnectButton,
handleGoogleAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
@@ -68,7 +74,17 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
@@ -77,6 +93,19 @@ export const ManageIntegration = ({
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setSelectedIntegration(null);

View File

@@ -21,6 +21,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -256,10 +257,16 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl)
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -368,7 +375,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => choice.imageUrl);
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
}
});

View File

@@ -18,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
@@ -95,12 +96,15 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,

View File

@@ -1,49 +0,0 @@
import cubejs, { Query } from "@cubejs-client/core";
/**
* Cube.js client for executing analytics queries.
*
* Authentication is handled via the CUBEJS_API_SECRET env var which must match
* the secret configured on the Cube.js server. In development an empty token is
* accepted when the Cube.js instance runs in dev mode.
*/
const getApiUrl = () => {
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
if (baseUrl.includes("/cubejs-api/v1")) {
return baseUrl;
}
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
};
const API_URL = getApiUrl();
export function createCubeClient() {
const token = process.env.CUBEJS_API_TOKEN ?? "";
console.log(`[CubeClient] Connecting to ${API_URL} (token ${token ? "set" : "empty"})`);
return cubejs(token, {
apiUrl: API_URL,
});
}
/**
* Execute a Cube.js query and return the table pivot data.
*/
export async function executeQuery(query: Query) {
console.log("[CubeClient] executeQuery called with:", JSON.stringify(query, null, 2));
const client = createCubeClient();
try {
const resultSet = await client.load(query);
const rows = resultSet.tablePivot();
console.log(`[CubeClient] Query succeeded — ${rows.length} row(s) returned`);
return rows;
} catch (error) {
console.error("[CubeClient] Query failed:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
apiUrl: API_URL,
query,
});
throw error;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
@@ -8,7 +9,7 @@ import {
WEBAPP_URL,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -42,33 +43,39 @@ export const GET = async (req: Request) => {
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
let key;
let userEmail;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
if (!code) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;
if (!key) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
oAuth2Client.setCredentials({ access_token: key.access_token });
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
const userInfo = await oauth2.userinfo.get();
const userEmail = userInfo.data.email;
if (!userEmail) {
return responses.internalServerErrorResponse("Failed to get user email");
}
const integrationType = "googleSheets" as const;
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
const googleSheetIntegration = {
type: "googleSheets" as "googleSheets",
type: integrationType,
environment: environmentId,
config: {
key,
data: [],
data: existingConfig?.data ?? [],
email: userEmail,
},
};

View File

@@ -10,6 +10,7 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: environmentData.project.styling,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: transformedSurveys,
surveys: resolveStorageUrlsInObject(transformedSurveys),
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -44,13 +44,10 @@ const validateResponse = (
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);

View File

@@ -41,7 +41,6 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse(result.response),
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
};
} catch (error) {
return {
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -190,7 +192,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse(updated),
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
};
} catch (error) {
return {

View File

@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(allResponses),
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -155,7 +157,6 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -16,6 +16,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
};
}
return {
response: responses.successResponse(result.survey),
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
};
} catch (error) {
return {
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(surveyWithQuestions),
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
};
}
return {
response: responses.successResponse(updatedSurvey),
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
};
} catch (error) {
return {

View File

@@ -14,6 +14,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(surveysWithQuestions),
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -112,7 +112,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -257,6 +257,7 @@ describe("endpoint-validator", () => {
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
expect(isAuthProtectedRoute("/health")).toBe(false);
});
@@ -312,6 +313,19 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/c")).toBe(false);
expect(isPublicDomainRoute("/contact/token")).toBe(false);
});
test("should return true for pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
});
test("should return false for malformed pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/")).toBe(false);
expect(isPublicDomainRoute("/p")).toBe(false);
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
});
test("should return true for client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
@@ -375,6 +389,8 @@ describe("endpoint-validator", () => {
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
});
@@ -390,6 +406,7 @@ describe("endpoint-validator", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
@@ -426,6 +443,8 @@ describe("endpoint-validator", () => {
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
});
@@ -440,6 +459,8 @@ describe("endpoint-validator", () => {
test("should handle paths with query parameters and fragments", () => {
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
});
@@ -450,6 +471,7 @@ describe("endpoint-validator", () => {
describe("URL parsing edge cases", () => {
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
@@ -458,12 +480,14 @@ describe("endpoint-validator", () => {
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
});
test("should handle trailing slashes", () => {
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
isManagementApi: true,
@@ -478,6 +502,9 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
});
test("should handle nested client API routes", () => {
@@ -529,6 +556,7 @@ describe("endpoint-validator", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
});
});
@@ -536,6 +564,7 @@ describe("endpoint-validator", () => {
test("should properly validate malicious or injection-like URLs", () => {
// SQL injection-like attempts
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
@@ -543,10 +572,12 @@ describe("endpoint-validator", () => {
// Path traversal attempts
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
// XSS-like attempts
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
isClientSideApi: true,
isRateLimited: true,
@@ -556,6 +587,7 @@ describe("endpoint-validator", () => {
test("should handle URL encoding", () => {
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
isManagementApi: true,
@@ -591,6 +623,7 @@ describe("endpoint-validator", () => {
// These should not match due to case sensitivity
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,

View File

@@ -7,6 +7,7 @@ const PUBLIC_ROUTES = {
SURVEY_ROUTES: [
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
],
// API routes accessible from public domain

View File

@@ -106,7 +106,6 @@ checksums:
common/allow: 3e39cc5940255e6bff0fea95c817dd43
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
common/and: dc75b95c804b16dc617a5f16f7393bca
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
@@ -712,7 +711,12 @@ checksums:
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
environments/integrations/google_sheets/reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/google_sheets/reconnect_button_description: 851fd2fda57211293090f371d5b2c734
environments/integrations/google_sheets/reconnect_button_tooltip: 210dd97470fde8264d2c076db3c98fde
environments/integrations/google_sheets/spreadsheet_permission_error: 94f0007a187d3b9a7ab8200fe26aad20
environments/integrations/google_sheets/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5

View File

@@ -0,0 +1,6 @@
/**
* Error codes returned by Google Sheets integration.
* Use these constants when comparing error responses to avoid typos and enable reuse.
*/
export const GOOGLE_SHEET_INTEGRATION_INVALID_GRANT = "invalid_grant";
export const GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION = "insufficient_permission";

View File

@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {
AuthenticationError,
DatabaseError,
OperationNotAllowedError,
UnknownError,
} from "@formbricks/types/errors";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
@@ -11,8 +16,12 @@ import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
GOOGLE_SHEET_MESSAGE_LIMIT,
} from "@/lib/constants";
import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { truncateText } from "../utils/strings";
import { validateInputs } from "../utils/validate";
@@ -81,6 +90,17 @@ export const writeData = async (
}
};
export const validateGoogleSheetsConnection = async (
googleSheetIntegrationData: TIntegrationGoogleSheets
): Promise<void> => {
validateInputs([googleSheetIntegrationData, ZIntegrationGoogleSheets]);
const integrationData = structuredClone(googleSheetIntegrationData);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
await authorize(integrationData);
};
export const getSpreadsheetNameById = async (
googleSheetIntegrationData: TIntegrationGoogleSheets,
spreadsheetId: string
@@ -94,7 +114,17 @@ export const getSpreadsheetNameById = async (
return new Promise((resolve, reject) => {
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
if (err) {
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
const msg = err.message?.toLowerCase() ?? "";
const isPermissionError =
msg.includes("permission") ||
msg.includes("caller does not have") ||
msg.includes("insufficient permission") ||
msg.includes("access denied");
if (isPermissionError) {
reject(new OperationNotAllowedError(GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION));
} else {
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
}
return;
}
const spreadsheetTitle = response.data.properties.title;
@@ -109,26 +139,70 @@ export const getSpreadsheetNameById = async (
}
};
const isInvalidGrantError = (error: unknown): boolean => {
const err = error as { message?: string; response?: { data?: { error?: string } } };
return (
typeof err?.message === "string" &&
err.message.toLowerCase().includes(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT)
);
};
/** Buffer in ms before expiry_date to consider token near-expired (5 minutes). */
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
const GOOGLE_TOKENINFO_URL = "https://www.googleapis.com/oauth2/v1/tokeninfo";
/**
* Verifies that the access token is still valid and not revoked (e.g. user removed app access).
* Returns true if token is valid, false if invalid/revoked.
*/
const isAccessTokenValid = async (accessToken: string): Promise<boolean> => {
try {
const res = await fetch(`${GOOGLE_TOKENINFO_URL}?access_token=${encodeURIComponent(accessToken)}`);
return res.ok;
} catch {
return false;
}
};
const authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
oAuth2Client.setCredentials({
refresh_token,
});
const { credentials } = await oAuth2Client.refreshAccessToken();
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: credentials,
},
});
const key = googleSheetIntegrationData.config.key;
oAuth2Client.setCredentials(credentials);
const hasStoredCredentials =
key.access_token && key.expiry_date && key.expiry_date > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
return oAuth2Client;
if (hasStoredCredentials && (await isAccessTokenValid(key.access_token))) {
oAuth2Client.setCredentials(key);
return oAuth2Client;
}
oAuth2Client.setCredentials({ refresh_token: key.refresh_token });
try {
const { credentials } = await oAuth2Client.refreshAccessToken();
const mergedCredentials = {
...credentials,
refresh_token: credentials.refresh_token ?? key.refresh_token,
};
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: mergedCredentials,
},
});
oAuth2Client.setCredentials(mergedCredentials);
return oAuth2Client;
} catch (error) {
if (isInvalidGrantError(error)) {
throw new AuthenticationError(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT);
}
throw error;
}
};

View File

@@ -22,6 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -408,9 +409,10 @@ export const getResponseDownloadFile = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
const jsonData = getResponsesJson(
survey,
responses,
resolvedResponses,
elements,
userAttributes,
hiddenFields,

View File

@@ -149,6 +149,42 @@ export const STYLE_DEFAULTS: TProjectStyling = {
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
};
/**
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
*
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
*
* When loading v4.6 data the new fields are absent. Without this helper the
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
* colour), causing a visible mismatch. This function derives the new fields
* from the actually-saved legacy fields so the preview and form stay coherent.
*
* Only sets a field when the legacy source exists AND the new field is absent.
*/
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
const light = (key: string): string | undefined =>
(saved[key] as { light?: string } | null | undefined)?.light;
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b &&
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
};
/**
* Builds a complete TProjectStyling object from a single brand color.
*

View File

@@ -22,9 +22,6 @@ export type AuditLoggingCtx = {
quotaId?: string;
teamId?: string;
integrationId?: string;
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
};
export type ActionClientCtx = {

View File

@@ -133,7 +133,6 @@
"allow": "erlauben",
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
"analysis": "Analyse",
"and": "und",
"and_response_limit_of": "und Antwortlimit von",
"anonymous": "Anonym",
@@ -753,7 +752,12 @@
"link_google_sheet": "Tabelle verlinken",
"link_new_sheet": "Neues Blatt verknüpfen",
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"spreadsheet_url": "Tabellen-URL"
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Google Sheets-Verbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"spreadsheet_permission_error": "Du hast keine Berechtigung, auf diese Tabelle zuzugreifen. Bitte stelle sicher, dass die Tabelle mit deinem Google-Konto geteilt ist und du Schreibzugriff auf die Tabelle hast.",
"spreadsheet_url": "Tabellen-URL",
"token_expired_error": "Das Google Sheets-Aktualisierungstoken ist abgelaufen oder wurde widerrufen. Bitte verbinde die Integration erneut."
},
"include_created_at": "Erstellungsdatum einbeziehen",
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",

View File

@@ -122,7 +122,6 @@
"activity": "Activity",
"add": "Add",
"add_action": "Add action",
"add_chart": "Add chart",
"add_filter": "Add filter",
"add_logo": "Add logo",
"add_member": "Add member",
@@ -134,7 +133,6 @@
"allow": "Allow",
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
"analysis": "Analysis",
"and": "And",
"and_response_limit_of": "and response limit of",
"anonymous": "Anonymous",
@@ -401,7 +399,6 @@
"styling": "Styling",
"submit": "Submit",
"summary": "Summary",
"chart": "Chart",
"survey": "Survey",
"survey_completed": "Survey completed.",
"survey_id": "Survey ID",
@@ -621,17 +618,6 @@
"subtitle": "It takes less than 4 minutes.",
"waiting_for_your_signal": "Waiting for your signal…"
},
"analysis": {
"charts": {
"edit_chart_description": "View and edit your chart configuration.",
"edit_chart_title": "Edit Chart",
"chart_deleted_successfully": "Chart deleted successfully",
"chart_duplicated_successfully": "Chart duplicated successfully",
"chart_duplication_error": "Failed to duplicate chart",
"delete_chart_confirmation": "Are you sure you want to delete this chart? This action cannot be undone.",
"open_options": "Open options"
}
},
"contacts": {
"add_attribute": "Add Attribute",
"attribute_created_successfully": "Attribute created successfully",
@@ -766,7 +752,12 @@
"link_google_sheet": "Link Google Sheet",
"link_new_sheet": "Link new Sheet",
"no_integrations_yet": "Your google sheet integrations will appear here as soon as you add them. ⏲️",
"spreadsheet_url": "Spreadsheet URL"
"reconnect_button": "Reconnect",
"reconnect_button_description": "Your Google Sheets connection has expired. Please reconnect to continue syncing responses. Your existing spreadsheet links and data will be preserved.",
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing spreadsheet links and data will be preserved.",
"spreadsheet_permission_error": "You don't have permission to access this spreadsheet. Please ensure the spreadsheet is shared with your Google account and you have write access to the spreadsheet.",
"spreadsheet_url": "Spreadsheet URL",
"token_expired_error": "Google Sheets refresh token has expired or been revoked. Please reconnect the integration."
},
"include_created_at": "Include Created At",
"include_hidden_fields": "Include Hidden Fields",

View File

@@ -133,7 +133,6 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
"analysis": "Análisis",
"and": "Y",
"and_response_limit_of": "y límite de respuesta de",
"anonymous": "Anónimo",
@@ -753,7 +752,12 @@
"link_google_sheet": "Vincular Google Sheet",
"link_new_sheet": "Vincular nueva hoja",
"no_integrations_yet": "Tus integraciones de Google Sheet aparecerán aquí tan pronto como las añadas. ⏲️",
"spreadsheet_url": "URL de la hoja de cálculo"
"reconnect_button": "Reconectar",
"reconnect_button_description": "Tu conexión con Google Sheets ha caducado. Reconecta para continuar sincronizando respuestas. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
"spreadsheet_permission_error": "No tienes permiso para acceder a esta hoja de cálculo. Asegúrate de que la hoja de cálculo esté compartida con tu cuenta de Google y de que tengas acceso de escritura a la hoja de cálculo.",
"spreadsheet_url": "URL de la hoja de cálculo",
"token_expired_error": "El token de actualización de Google Sheets ha caducado o ha sido revocado. Reconecta la integración."
},
"include_created_at": "Incluir fecha de creación",
"include_hidden_fields": "Incluir campos ocultos",

View File

@@ -133,7 +133,6 @@
"allow": "Autoriser",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
"analysis": "Analyse",
"and": "Et",
"and_response_limit_of": "et limite de réponse de",
"anonymous": "Anonyme",
@@ -753,7 +752,12 @@
"link_google_sheet": "Lien Google Sheet",
"link_new_sheet": "Lier une nouvelle feuille",
"no_integrations_yet": "Vos intégrations Google Sheets apparaîtront ici dès que vous les ajouterez. ⏲️",
"spreadsheet_url": "URL de la feuille de calcul"
"reconnect_button": "Reconnecter",
"reconnect_button_description": "Votre connexion Google Sheets a expiré. Veuillez vous reconnecter pour continuer à synchroniser les réponses. Vos liens de feuilles de calcul et données existants seront préservés.",
"reconnect_button_tooltip": "Reconnectez l'intégration pour actualiser votre accès. Vos liens de feuilles de calcul et données existants seront préservés.",
"spreadsheet_permission_error": "Vous n'avez pas la permission d'accéder à cette feuille de calcul. Veuillez vous assurer que la feuille de calcul est partagée avec votre compte Google et que vous disposez d'un accès en écriture.",
"spreadsheet_url": "URL de la feuille de calcul",
"token_expired_error": "Le jeton d'actualisation Google Sheets a expiré ou a été révoqué. Veuillez reconnecter l'intégration."
},
"include_created_at": "Inclure la date de création",
"include_hidden_fields": "Inclure les champs cachés",

View File

@@ -133,7 +133,6 @@
"allow": "Engedélyezés",
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
"analysis": "Elemzés",
"and": "És",
"and_response_limit_of": "és kérdéskorlátja ennek:",
"anonymous": "Névtelen",
@@ -753,7 +752,12 @@
"link_google_sheet": "Google Táblázatok összekapcsolása",
"link_new_sheet": "Új táblázat összekapcsolása",
"no_integrations_yet": "A Google Táblázatok integrációi itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"spreadsheet_url": "Táblázat URL-e"
"reconnect_button": "Újrakapcsolódás",
"reconnect_button_description": "A Google Táblázatok kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő táblázathivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő táblázathivatkozások és adatok megmaradnak.",
"spreadsheet_permission_error": "Nincs jogosultsága a táblázat eléréséhez. Kérjük, győződjön meg arról, hogy a táblázat meg van osztva a Google-fiókjával, és írási jogosultsággal rendelkezik a táblázathoz.",
"spreadsheet_url": "Táblázat URL-e",
"token_expired_error": "A Google Táblázatok frissítési tokenje lejárt vagy visszavonásra került. Kérjük, csatlakoztassa újra az integrációt."
},
"include_created_at": "Létrehozva felvétele",
"include_hidden_fields": "Rejtett mezők felvétele",

View File

@@ -133,7 +133,6 @@
"allow": "許可",
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
"analysis": "分析",
"and": "および",
"and_response_limit_of": "と回答数の上限",
"anonymous": "匿名",
@@ -753,7 +752,12 @@
"link_google_sheet": "スプレッドシートをリンク",
"link_new_sheet": "新しいシートをリンク",
"no_integrations_yet": "Google スプレッドシート連携は、追加するとここに表示されます。⏲️",
"spreadsheet_url": "スプレッドシートURL"
"reconnect_button": "再接続",
"reconnect_button_description": "Google Sheetsの接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のスプレッドシートリンクとデータは保持されます。",
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のスプレッドシートリンクとデータは保持されます。",
"spreadsheet_permission_error": "このスプレッドシートにアクセスする権限がありません。スプレッドシートがGoogleアカウントと共有されており、書き込みアクセス権があることを確認してください。",
"spreadsheet_url": "スプレッドシートURL",
"token_expired_error": "Google Sheetsのリフレッシュトークンが期限切れになったか、取り消されました。統合を再接続してください。"
},
"include_created_at": "作成日時を含める",
"include_hidden_fields": "非表示フィールドを含める",

View File

@@ -133,7 +133,6 @@
"allow": "Toestaan",
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
"analysis": "Analyse",
"and": "En",
"and_response_limit_of": "en responslimiet van",
"anonymous": "Anoniem",
@@ -753,7 +752,12 @@
"link_google_sheet": "Link Google Spreadsheet",
"link_new_sheet": "Nieuw blad koppelen",
"no_integrations_yet": "Uw Google Spreadsheet-integraties verschijnen hier zodra u ze toevoegt. ⏲️",
"spreadsheet_url": "Spreadsheet-URL"
"reconnect_button": "Maak opnieuw verbinding",
"reconnect_button_description": "Je Google Sheets-verbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van antwoorden. Je bestaande spreadsheetlinks en gegevens blijven behouden.",
"reconnect_button_tooltip": "Maak opnieuw verbinding met de integratie om je toegang te vernieuwen. Je bestaande spreadsheetlinks en gegevens blijven behouden.",
"spreadsheet_permission_error": "Je hebt geen toestemming om deze spreadsheet te openen. Zorg ervoor dat de spreadsheet is gedeeld met je Google-account en dat je schrijftoegang hebt tot de spreadsheet.",
"spreadsheet_url": "Spreadsheet-URL",
"token_expired_error": "Het vernieuwingstoken van Google Sheets is verlopen of ingetrokken. Maak opnieuw verbinding met de integratie."
},
"include_created_at": "Inclusief gemaakt op",
"include_hidden_fields": "Inclusief verborgen velden",

View File

@@ -133,7 +133,6 @@
"allow": "permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anônimo",
@@ -753,7 +752,12 @@
"link_google_sheet": "Link da Planilha do Google",
"link_new_sheet": "Vincular nova planilha",
"no_integrations_yet": "Suas integrações do Google Sheets vão aparecer aqui assim que você adicioná-las. ⏲️",
"spreadsheet_url": "URL da planilha"
"reconnect_button": "Reconectar",
"reconnect_button_description": "Sua conexão com o Google Sheets expirou. Reconecte para continuar sincronizando respostas. Seus links de planilhas e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links de planilhas e dados existentes serão preservados.",
"spreadsheet_permission_error": "Você não tem permissão para acessar esta planilha. Certifique-se de que a planilha está compartilhada com sua conta do Google e que você tem acesso de escrita à planilha.",
"spreadsheet_url": "URL da planilha",
"token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Reconecte a integração."
},
"include_created_at": "Incluir Data de Criação",
"include_hidden_fields": "Incluir Campos Ocultos",

View File

@@ -133,7 +133,6 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo",
@@ -753,7 +752,12 @@
"link_google_sheet": "Ligar Folha do Google",
"link_new_sheet": "Ligar nova Folha",
"no_integrations_yet": "As suas integrações com o Google Sheets aparecerão aqui assim que as adicionar. ⏲️",
"spreadsheet_url": "URL da folha de cálculo"
"reconnect_button": "Reconectar",
"reconnect_button_description": "A tua ligação ao Google Sheets expirou. Por favor, reconecta para continuar a sincronizar respostas. As tuas ligações de folhas de cálculo e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecta a integração para atualizar o teu acesso. As tuas ligações de folhas de cálculo e dados existentes serão preservados.",
"spreadsheet_permission_error": "Não tens permissão para aceder a esta folha de cálculo. Por favor, certifica-te de que a folha de cálculo está partilhada com a tua conta Google e que tens acesso de escrita à folha de cálculo.",
"spreadsheet_url": "URL da folha de cálculo",
"token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Por favor, reconecta a integração."
},
"include_created_at": "Incluir Criado Em",
"include_hidden_fields": "Incluir Campos Ocultos",

View File

@@ -133,7 +133,6 @@
"allow": "Permite",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
"analysis": "Analiză",
"and": "Și",
"and_response_limit_of": "și limită răspuns",
"anonymous": "Anonim",
@@ -753,7 +752,12 @@
"link_google_sheet": "Leagă Google Sheet",
"link_new_sheet": "Leagă un nou Sheet",
"no_integrations_yet": "Integrațiile tale Google Sheet vor apărea aici de îndată ce le vei adăuga. ⏲️",
"spreadsheet_url": "URL foaie de calcul"
"reconnect_button": "Reconectează",
"reconnect_button_description": "Conexiunea ta cu Google Sheets a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele existente din foile de calcul vor fi păstrate.",
"reconnect_button_tooltip": "Reconectează integrarea pentru a-ți reîmprospăta accesul. Linkurile și datele existente din foile de calcul vor fi păstrate.",
"spreadsheet_permission_error": "Nu ai permisiunea de a accesa această foaie de calcul. Asigură-te că foaia de calcul este partajată cu contul tău Google și că ai acces de scriere la aceasta.",
"spreadsheet_url": "URL foaie de calcul",
"token_expired_error": "Tokenul de reîmprospătare Google Sheets a expirat sau a fost revocat. Te rugăm să reconectezi integrarea."
},
"include_created_at": "Include data creării",
"include_hidden_fields": "Include câmpuri ascunse",

View File

@@ -133,7 +133,6 @@
"allow": "Разрешить",
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
"analysis": "Анализ",
"and": "и",
"and_response_limit_of": "и лимит ответов",
"anonymous": "Аноним",
@@ -753,7 +752,12 @@
"link_google_sheet": "Связать с Google Sheet",
"link_new_sheet": "Связать с новой таблицей",
"no_integrations_yet": "Ваши интеграции с Google Sheet появятся здесь, как только вы их добавите. ⏲️",
"spreadsheet_url": "URL таблицы"
"reconnect_button": "Переподключить",
"reconnect_button_description": "Срок действия подключения к Google Sheets истёк. Пожалуйста, переподключись, чтобы продолжить синхронизацию ответов. Все существующие ссылки на таблицы и данные будут сохранены.",
"reconnect_button_tooltip": "Переподключи интеграцию, чтобы обновить доступ. Все существующие ссылки на таблицы и данные будут сохранены.",
"spreadsheet_permission_error": "У тебя нет доступа к этой таблице. Убедись, что таблица открыта для твоего Google-аккаунта и у тебя есть права на запись.",
"spreadsheet_url": "URL таблицы",
"token_expired_error": "Срок действия токена обновления Google Sheets истёк или он был отозван. Пожалуйста, переподключи интеграцию."
},
"include_created_at": "Включить дату создания",
"include_hidden_fields": "Включить скрытые поля",

View File

@@ -133,7 +133,6 @@
"allow": "Tillåt",
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
"analysis": "Analys",
"and": "Och",
"and_response_limit_of": "och svarsgräns på",
"anonymous": "Anonym",
@@ -753,7 +752,12 @@
"link_google_sheet": "Länka Google Kalkylark",
"link_new_sheet": "Länka nytt kalkylark",
"no_integrations_yet": "Dina Google Kalkylark-integrationer visas här så snart du lägger till dem. ⏲️",
"spreadsheet_url": "Kalkylblads-URL"
"reconnect_button": "Återanslut",
"reconnect_button_description": "Din Google Sheets-anslutning har gått ut. Återanslut för att fortsätta synkronisera svar. Dina befintliga kalkylarkslänkar och data kommer att sparas.",
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga kalkylarkslänkar och data kommer att sparas.",
"spreadsheet_permission_error": "Du har inte behörighet att komma åt det här kalkylarket. Kontrollera att kalkylarket är delat med ditt Google-konto och att du har skrivrättigheter till kalkylarket.",
"spreadsheet_url": "Kalkylblads-URL",
"token_expired_error": "Google Sheets refresh token har gått ut eller återkallats. Återanslut integrationen."
},
"include_created_at": "Inkludera Skapad vid",
"include_hidden_fields": "Inkludera dolda fält",

View File

@@ -133,7 +133,6 @@
"allow": "允许",
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
"analysis": "分析",
"and": "和",
"and_response_limit_of": "和 响应限制",
"anonymous": "匿名",
@@ -753,7 +752,12 @@
"link_google_sheet": "链接 Google 表格",
"link_new_sheet": "链接 新 表格",
"no_integrations_yet": "您的 Google Sheet 集成会在您 添加 后 出现在这里。 ⏲️",
"spreadsheet_url": "电子表格 URL"
"reconnect_button": "重新连接",
"reconnect_button_description": "你的 Google Sheets 连接已过期。请重新连接以继续同步回复。你现有的表格链接和数据会被保留。",
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的表格链接和数据会被保留。",
"spreadsheet_permission_error": "你没有权限访问此表格。请确保该表格已与你的 Google 账号共享,并且你拥有该表格的编辑权限。",
"spreadsheet_url": "电子表格 URL",
"token_expired_error": "Google Sheets 的刷新令牌已过期或被撤销。请重新连接集成。"
},
"include_created_at": "包括 创建 于",
"include_hidden_fields": "包括 隐藏 字段",

View File

@@ -133,7 +133,6 @@
"allow": "允許",
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
"analysis": "分析",
"and": "且",
"and_response_limit_of": "且回應上限為",
"anonymous": "匿名",
@@ -753,7 +752,12 @@
"link_google_sheet": "連結 Google 試算表",
"link_new_sheet": "連結新試算表",
"no_integrations_yet": "您的 Google 試算表整合將在您新增後立即顯示在此處。⏲️",
"spreadsheet_url": "試算表網址"
"reconnect_button": "重新連線",
"reconnect_button_description": "你的 Google Sheets 連線已過期。請重新連線以繼續同步回應。你現有的試算表連結和資料都會被保留。",
"reconnect_button_tooltip": "重新連線整合以刷新存取權限。你現有的試算表連結和資料都會被保留。",
"spreadsheet_permission_error": "你沒有權限存取這個試算表。請確認該試算表已與你的 Google 帳戶分享,且你擁有寫入權限。",
"spreadsheet_url": "試算表網址",
"token_expired_error": "Google Sheets 的刷新權杖已過期或被撤銷。請重新連線整合。"
},
"include_created_at": "包含建立於",
"include_hidden_fields": "包含隱藏欄位",

View File

@@ -42,7 +42,7 @@ export const SingleResponseCardBody = ({
return (
<span
key={index}
className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
className="mr-0.5 ml-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
@{part}
</span>
);

View File

@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", true, mockQuestions);
validateResponseData([], mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
});
test("should use default language code", () => {
@@ -125,25 +125,58 @@ describe("validateResponseData", () => {
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
test("should validate only present fields when finished is false", () => {
test("should validate only fields present in responseData", () => {
const partialResponseData: TResponseData = { element1: "test" };
const partialElements = [mockElements[0]];
const elementsToValidate = [mockElements[0]];
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", false);
validateResponseData(mockBlocks, partialResponseData, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(elementsToValidate, partialResponseData, "en");
});
test("should validate all fields when finished is true", () => {
const partialResponseData: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(mockElements);
test("should never validate elements not in responseData", () => {
const blocksWithTwoElements: TSurveyBlock[] = [
...mockBlocks,
{
id: "block2",
name: "Block 2",
elements: [
{
id: "element2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
inputType: "text",
charLimit: { enabled: false },
},
],
},
];
const allElements = [
...mockElements,
{
id: "element2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
inputType: "text",
charLimit: { enabled: false },
},
];
const responseDataWithOnlyElement1: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(allElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", true);
validateResponseData(blocksWithTwoElements, responseDataWithOnlyElement1, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
// Only element1 should be validated, not element2 (even though it's required)
expect(mockValidateBlockResponses).toHaveBeenCalledWith(
[allElements[0]],
responseDataWithOnlyElement1,
"en"
);
});
});

View File

@@ -9,13 +9,13 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
/**
* Validates response data against survey validation rules
* Handles partial responses (in-progress) by only validating present fields when finished is false
* Validates response data against survey validation rules.
* Only validates elements that have data in responseData - never validates
* all survey elements regardless of completion status.
*
* @param blocks - Survey blocks containing elements with validation rules (preferred)
* @param responseData - Response data to validate (keyed by element ID)
* @param languageCode - Language code for error messages (defaults to "en")
* @param finished - Whether the response is finished (defaults to true for management APIs)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @returns Validation error map keyed by element ID, or null if validation passes
*/
@@ -23,7 +23,6 @@ export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
finished: boolean = true,
questions?: TSurveyQuestion[] | undefined | null
): TValidationErrorMap | null => {
// Use blocks if available, otherwise transform questions to blocks
@@ -42,11 +41,8 @@ export const validateResponseData = (
// Extract elements from blocks
const allElements = getElementsFromBlocks(blocksToUse);
// If response is not finished, only validate elements that are present in the response data
// This prevents "required" errors for fields the user hasn't reached yet
const elementsToValidate = finished
? allElements
: allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Always validate only elements that are present in responseData
const elementsToValidate = allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Validate selected elements
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);

View File

@@ -15,7 +15,7 @@ import {
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -51,7 +51,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, response.error as ApiErrorResponseV2);
}
return responses.successResponse(response);
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
},
});
@@ -198,7 +201,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
body.finished,
questionsResponse.data.questions
);
@@ -244,7 +246,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
auditLog.newObject = response.data;
}
return responses.successResponse(response);
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
},
action: "updated",
targetType: "response",

View File

@@ -12,7 +12,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
export const GET = async (request: NextRequest) =>
@@ -44,7 +44,9 @@ export const GET = async (request: NextRequest) =>
environmentResponses.push(...res.data.data);
return responses.successResponse({ data: environmentResponses });
return responses.successResponse({
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
});
},
});
@@ -134,7 +136,6 @@ export const POST = async (request: Request) =>
surveyQuestions.data.blocks,
body.data,
body.language ?? "en",
body.finished,
surveyQuestions.data.questions
);

View File

@@ -1,425 +0,0 @@
"use server";
import { ChartType } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { executeQuery } from "@/app/api/analytics/_lib/cube-client";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ZChartConfig, ZChartType, ZCubeQuery } from "../types/analysis";
const ZCreateChartAction = z.object({
environmentId: ZId,
name: z.string().min(1),
type: ZChartType,
query: ZCubeQuery,
config: ZChartConfig.optional().default({}),
});
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
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: ZChartType.optional(),
query: ZCubeQuery.optional(),
config: ZChartConfig.optional(),
});
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
withAuditLogging(
"updated",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
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,
},
],
});
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!chart) {
throw new Error("Chart not found");
}
const updateData: {
name?: string;
type?: ChartType;
query?: z.infer<typeof ZCubeQuery>;
config?: z.infer<typeof ZChartConfig>;
} = {};
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 ZDuplicateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateChartAction>;
}) => {
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 sourceChart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!sourceChart) {
throw new Error("Chart not found");
}
const duplicatedChart = await prisma.chart.create({
data: {
name: `${sourceChart.name} (copy)`,
type: sourceChart.type,
projectId,
query: sourceChart.query as object,
config: (sourceChart.config as object) || {},
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = duplicatedChart;
return duplicatedChart;
}
)
);
const ZDeleteChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const deleteChartAction = authenticatedActionClient.schema(ZDeleteChartAction).action(
withAuditLogging(
"deleted",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteChartAction>;
}) => {
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.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!chart) {
throw new Error("Chart not found");
}
await prisma.dashboardWidget.deleteMany({
where: { chartId: parsedInput.chartId },
});
await prisma.chart.delete({
where: { id: parsedInput.chartId },
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = chart;
return { success: true };
}
)
);
const ZGetChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const getChartAction = authenticatedActionClient
.schema(ZGetChartAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
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: AuthenticatedActionClientCtx;
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: ZCubeQuery,
});
export const executeQueryAction = authenticatedActionClient
.schema(ZExecuteQueryAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
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 {
console.log("[executeQueryAction] Executing query:", JSON.stringify(parsedInput.query, null, 2));
const data = await executeQuery(parsedInput.query as Record<string, unknown>);
console.log(`[executeQueryAction] Success — ${Array.isArray(data) ? data.length : 0} row(s)`);
return { data };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to execute query";
console.error("[executeQueryAction] Failed:", {
error: message,
stack: error instanceof Error ? error.stack : undefined,
query: parsedInput.query,
});
return { error: message };
}
}
);

View File

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

View File

@@ -1,639 +0,0 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CodeIcon, DatabaseIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useEffect, useReducer, useState } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { Button } from "@/modules/ui/components/button";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { createChartAction, executeQueryAction } from "../../actions";
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
import { CHART_TYPES } from "../../lib/chart-types";
import { mapChartType } from "../../lib/chart-utils";
import {
ChartBuilderState,
CustomMeasure,
FilterRow,
TimeDimensionConfig,
buildCubeQuery,
parseQueryToState,
} from "@/modules/ee/analysis/lib/query-builder";
import { TCubeQuery } from "@/modules/ee/analysis/types/analysis";
import { AddToDashboardDialog } from "./add-to-dashboard-dialog";
import { ChartRenderer } from "./chart-renderer";
import { DimensionsPanel } from "./dimensions-panel";
import { FiltersPanel } from "./filters-panel";
import { MeasuresPanel } from "./measures-panel";
import { SaveChartDialog } from "./save-chart-dialog";
import { TimeDimensionPanel } from "./time-dimension-panel";
// Filter out table, map, and scatter charts
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter((type) => !["table", "map", "scatter"].includes(type.id));
interface AdvancedChartBuilderProps {
environmentId: string;
initialChartType?: string;
initialQuery?: TCubeQuery;
hidePreview?: boolean;
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, unknown>[] | null>(null);
const [query, setQuery] = useState<TCubeQuery | null>(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 AnalyticsResponse["chartType"],
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 AnalyticsResponse["chartType"], // 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 AnalyticsResponse["chartType"],
data,
};
onChartGenerated(analyticsResponse);
}
} else if (result?.serverError) {
setError(result.serverError);
}
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : "Failed to execute query";
setError(message);
})
.finally(() => {
setIsLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
state.chartType,
state.selectedMeasures,
state.selectedDimensions,
state.filters,
state.filterLogic,
state.customMeasures,
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 AnalyticsResponse["chartType"],
data,
};
onChartGenerated(analyticsResponse);
}
} else {
throw new Error("No data returned");
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.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: unknown) {
const errorMessage = error instanceof Error ? error.message : "Failed to save chart";
toast.error(errorMessage);
} 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);
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/dashboards/${selectedDashboardId}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Failed to add chart to dashboard";
toast.error(errorMessage);
} 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 as Record<string, unknown>[]} />
</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 &quot;Run Query&quot; to preview
</div>
)}
</div>
)}
{/* Dialogs - Only render when callbacks are not provided (standalone mode) */}
{!onSave && (
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
)}
{!onAddToDashboard && (
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
)}
</div>
);
}

View File

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

View File

@@ -1,324 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import {
createChartAction,
executeQueryAction,
getChartAction,
updateChartAction,
} from "../../actions";
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
import { mapChartType, mapDatabaseChartTypeToApi } from "../../lib/chart-utils";
import { AddToDashboardDialog } from "./add-to-dashboard-dialog";
import { AIQuerySection } from "./ai-query-section";
import { ChartPreview } from "./chart-preview";
import { ConfigureChartDialog } from "./configure-chart-dialog";
import { ManualChartBuilder } from "./manual-chart-builder";
import { SaveChartDialog } from "./save-chart-dialog";
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 [configuredChartType, setConfiguredChartType] = useState<string | null>(null);
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 AnalyticsResponse["query"],
});
if (queryResult?.data?.error || queryResult?.serverError) {
toast.error(queryResult.data?.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 AnalyticsResponse["query"],
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: unknown) => {
const errorMessage = error instanceof Error ? error.message : "Failed to load chart";
toast.error(errorMessage);
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: unknown) {
const errorMessage = error instanceof Error ? error.message : "Failed to save chart";
toast.error(errorMessage);
} 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/dashboards/${selectedDashboardId}`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Failed to add chart to dashboard";
toast.error(errorMessage);
} finally {
setIsSaving(false);
}
};
// 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} />
{/* 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} />}
<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 */}
<ManualChartBuilder selectedChartType={selectedChartType} onChartTypeSelect={setSelectedChartType} />
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
<ConfigureChartDialog
open={isConfigureDialogOpen}
onOpenChange={setIsConfigureDialogOpen}
currentChartType={chartData?.chartType || "bar"}
configuredChartType={configuredChartType}
onChartTypeSelect={setConfiguredChartType}
onReset={() => setConfiguredChartType(null)}
/>
</div>
);
}

View File

@@ -1,47 +0,0 @@
"use client";
import { BarChart, DatabaseIcon } from "lucide-react";
import { useState } from "react";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { ChartRenderer } from "./chart-renderer";
import { DataViewer } from "./data-viewer";
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="mb-4 flex justify-end">
<TabsList>
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
Chart
</TabsTrigger>
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
Data
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chart" className="mt-0">
<ChartRenderer chartType={chartData.chartType} data={chartData.data || []} />
</TabsContent>
<TabsContent value="data" className="mt-0">
<DataViewer data={chartData.data || []} />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -1,229 +0,0 @@
"use client";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
XAxis,
YAxis,
} from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
// Formbricks brand colors
const BRAND_DARK = "#00C4B8";
const BRAND_LIGHT = "#00E6CA";
interface ChartRendererProps {
chartType: string;
data: Record<string, unknown>[];
}
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: number | string, 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 &quot;{chartType}&quot; not yet supported
</div>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
export { ChartRenderer } from "./chart-renderer";
export { QueryViewer } from "./query-viewer";
export { DataViewer } from "./data-viewer";
export { AIQuerySection } from "./ai-query-section";
export { ManualChartBuilder } from "./manual-chart-builder";
export { ChartPreview } from "./chart-preview";
export { SaveChartDialog } from "./save-chart-dialog";
export { AddToDashboardDialog } from "./add-to-dashboard-dialog";
export { ConfigureChartDialog } from "./configure-chart-dialog";
export { ChartBuilderClient } from "./chart-builder-client";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,76 +0,0 @@
"use client";
import { PlusIcon, SaveIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import { DialogFooter } from "@/modules/ui/components/dialog";
import { AddToDashboardDialog } from "./chart-builder/add-to-dashboard-dialog";
import { SaveChartDialog } from "./chart-builder/save-chart-dialog";
interface ChartDialogFooterWithModalsProps {
chartName: string;
onChartNameChange: (name: string) => void;
dashboards: Array<{ id: string; name: string }>;
selectedDashboardId: string;
onDashboardSelect: (id: string) => void;
onAddToDashboard: () => void;
onSave: () => void;
isSaving: boolean;
isSaveDialogOpen: boolean;
onSaveDialogOpenChange: (open: boolean) => void;
isAddToDashboardDialogOpen: boolean;
onAddToDashboardDialogOpenChange: (open: boolean) => void;
}
export function ChartDialogFooterWithModals({
chartName,
onChartNameChange,
dashboards,
selectedDashboardId,
onDashboardSelect,
onAddToDashboard,
onSave,
isSaving,
isSaveDialogOpen,
onSaveDialogOpenChange,
isAddToDashboardDialogOpen,
onAddToDashboardDialogOpenChange,
}: ChartDialogFooterWithModalsProps) {
return (
<>
<DialogFooter>
<Button
variant="outline"
onClick={() => onAddToDashboardDialogOpenChange(true)}
disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
Add to Dashboard
</Button>
<Button onClick={() => onSaveDialogOpenChange(true)} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
Save Chart
</Button>
</DialogFooter>
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={onSaveDialogOpenChange}
chartName={chartName}
onChartNameChange={onChartNameChange}
onSave={onSave}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={onAddToDashboardDialogOpenChange}
chartName={chartName}
onChartNameChange={onChartNameChange}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={onDashboardSelect}
onAdd={onAddToDashboard}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -1,21 +0,0 @@
"use client";
import { Dialog, DialogContent } from "@/modules/ui/components/dialog";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ChartDialogLoadingViewProps {
open: boolean;
onClose: () => void;
}
export function ChartDialogLoadingView({ open, onClose }: ChartDialogLoadingViewProps) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-[90vw] max-h-[90vh] overflow-y-auto">
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,157 +0,0 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Button } from "@/modules/ui/components/button";
import { deleteChartAction, duplicateChartAction } from "../actions";
import { TChart } from "../../types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useRouter } from "next/navigation";
interface ChartDropdownMenuProps {
environmentId: string;
chart: TChart;
onEdit?: (chartId: string) => void;
}
export const ChartDropdownMenu = ({
environmentId,
chart,
onEdit,
}: ChartDropdownMenuProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDeleteChart = async (chartId: string) => {
setLoading(true);
try {
const result = await deleteChartAction({ environmentId, chartId });
if (result?.data) {
toast.success(t("environments.analysis.charts.chart_deleted_successfully"));
setDeleteDialogOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setLoading(false);
}
};
const closeDropdown = () => {
setTimeout(() => setIsDropDownOpen(false), 0);
};
const handleDuplicateChart = async () => {
closeDropdown();
setLoading(true);
try {
const result = await duplicateChartAction({ environmentId, chartId: chart.id });
if (result?.data) {
toast.success(t("environments.analysis.charts.chart_duplicated_successfully"));
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_duplication_error"));
}
} catch {
toast.error(t("environments.analysis.charts.chart_duplication_error"));
} finally {
setLoading(false);
}
};
const handleEdit = () => {
closeDropdown();
setTimeout(() => onEdit?.(chart.id), 0);
};
const handleOpenDeleteDialog = () => {
closeDropdown();
setTimeout(() => setDeleteDialogOpen(true), 0);
};
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>
<Button
variant="outline"
className="px-2"
onClick={(e) => e.stopPropagation()}>
<span className="sr-only">{t("environments.analysis.charts.open_options")}</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</Button>
</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();
handleEdit();
}}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
handleDuplicateChart();
}}
disabled={loading}>
<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();
handleOpenDeleteDialog();
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat={t("common.chart")}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteChart(chart.id)}
text={t("environments.analysis.charts.delete_chart_confirmation")}
isDeleting={loading}
/>
</div>
);
};

View File

@@ -1,39 +0,0 @@
const SKELETON_ROWS = 5;
function SkeletonRow() {
return (
<div className="grid h-12 w-full animate-pulse grid-cols-7 content-center p-2">
<div className="col-span-3 flex items-center gap-4 pl-6">
<div className="h-5 w-5 rounded bg-gray-200" />
<div className="h-4 w-36 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-16 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-24 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-20 rounded bg-gray-200" />
</div>
<div className="col-span-1" />
</div>
);
}
export function ChartsListSkeleton() {
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>
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<SkeletonRow key={i} />
))}
</div>
);
}

View File

@@ -1,107 +0,0 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { BarChart3Icon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CHART_TYPE_ICONS } from "../lib/chart-types";
import { TChart } from "../../types/analysis";
import { ChartDropdownMenu } from "./chart-dropdown-menu";
import { CreateChartDialog } from "./create-chart-dialog";
interface ChartsListProps {
charts: TChart[];
environmentId: string;
}
export function ChartsList({ charts, environmentId }: Readonly<ChartsListProps>) {
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const { t } = useTranslation();
const filteredCharts = charts;
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 = () => {
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">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</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 whitespace-nowrap text-center text-sm 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 whitespace-normal text-center text-sm 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}
onEdit={(chartId) => {
setEditingChartId(chartId);
setIsEditDialogOpen(true);
}}
/>
</div>
</div>
))}
</>
)}
<CreateChartDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
environmentId={environmentId}
chartId={editingChartId}
onSuccess={handleEditSuccess}
/>
</div>
);
}

View File

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

View File

@@ -1,112 +0,0 @@
"use client";
import { useCreateChartDialog } from "../hooks/use-create-chart-dialog";
import { CreateChartView } from "./create-chart-view";
import { ChartDialogLoadingView } from "./chart-dialog-loading-view";
import { EditChartView } from "./edit-chart-view";
export interface CreateChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
onSuccess?: () => void;
}
export function CreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
onSuccess,
}: Readonly<CreateChartDialogProps>) {
const hook = useCreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
onSuccess,
});
const {
chartData,
chartName,
setChartName,
selectedChartType,
setSelectedChartType,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
isSaving,
isLoadingChart,
shouldShowAdvancedBuilder,
handleChartGenerated,
handleSaveChart,
handleAddToDashboard,
handleClose,
handleAdvancedBuilderSave,
handleAdvancedBuilderAddToDashboard,
} = hook;
if (chartId && isLoadingChart) {
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
}
if (chartId && chartData) {
return (
<EditChartView
open={open}
onClose={handleClose}
environmentId={environmentId}
chartData={chartData}
chartName={chartName}
onChartNameChange={setChartName}
onChartGenerated={handleChartGenerated}
onAdvancedBuilderSave={handleAdvancedBuilderSave}
onAdvancedBuilderAddToDashboard={handleAdvancedBuilderAddToDashboard}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAddToDashboard={handleAddToDashboard}
onSave={handleSaveChart}
isSaving={isSaving}
isSaveDialogOpen={isSaveDialogOpen}
onSaveDialogOpenChange={setIsSaveDialogOpen}
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
onAddToDashboardDialogOpenChange={setIsAddToDashboardDialogOpen}
/>
);
}
return (
<CreateChartView
open={open}
onClose={handleClose}
environmentId={environmentId}
chartId={chartId}
chartData={chartData}
chartName={chartName}
onChartNameChange={setChartName}
selectedChartType={selectedChartType}
onSelectedChartTypeChange={setSelectedChartType}
shouldShowAdvancedBuilder={shouldShowAdvancedBuilder}
onChartGenerated={handleChartGenerated}
onAdvancedBuilderSave={handleAdvancedBuilderSave}
onAdvancedBuilderAddToDashboard={handleAdvancedBuilderAddToDashboard}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAddToDashboard={handleAddToDashboard}
onSave={handleSaveChart}
isSaving={isSaving}
isSaveDialogOpen={isSaveDialogOpen}
onSaveDialogOpenChange={setIsSaveDialogOpen}
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
onAddToDashboardDialogOpenChange={setIsAddToDashboardDialogOpen}
/>
);
}

View File

@@ -1,131 +0,0 @@
"use client";
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { TCubeQuery } from "../../types/analysis";
import { AdvancedChartBuilder } from "./chart-builder/advanced-chart-builder";
import { AIQuerySection } from "./chart-builder/ai-query-section";
import { ChartPreview } from "./chart-builder/chart-preview";
import { ManualChartBuilder } from "./chart-builder/manual-chart-builder";
import { ChartDialogFooterWithModals } from "./chart-dialog-footer-with-modals";
interface CreateChartViewProps {
open: boolean;
onClose: () => void;
environmentId: string;
chartId?: string;
chartData: AnalyticsResponse | null;
chartName: string;
onChartNameChange: (name: string) => void;
selectedChartType: string;
onSelectedChartTypeChange: (type: string) => void;
shouldShowAdvancedBuilder: boolean;
onChartGenerated: (data: AnalyticsResponse) => void;
onAdvancedBuilderSave: (savedChartId: string) => void;
onAdvancedBuilderAddToDashboard: (savedChartId: string, _dashboardId?: string) => void;
dashboards: Array<{ id: string; name: string }>;
selectedDashboardId: string;
onDashboardSelect: (id: string) => void;
onAddToDashboard: () => void;
onSave: () => void;
isSaving: boolean;
isSaveDialogOpen: boolean;
onSaveDialogOpenChange: (open: boolean) => void;
isAddToDashboardDialogOpen: boolean;
onAddToDashboardDialogOpenChange: (open: boolean) => void;
}
export function CreateChartView({
open,
onClose,
environmentId,
chartId,
chartData,
chartName,
onChartNameChange,
selectedChartType,
onSelectedChartTypeChange,
shouldShowAdvancedBuilder,
onChartGenerated,
onAdvancedBuilderSave,
onAdvancedBuilderAddToDashboard,
dashboards,
selectedDashboardId,
onDashboardSelect,
onAddToDashboard,
onSave,
isSaving,
isSaveDialogOpen,
onSaveDialogOpenChange,
isAddToDashboardDialogOpen,
onAddToDashboardDialogOpenChange,
}: Readonly<CreateChartViewProps>) {
const handleAdvancedChartGenerated = (data: AnalyticsResponse) => {
onChartGenerated(data);
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<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">
<AIQuerySection onChartGenerated={onChartGenerated} />
<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>
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={onSelectedChartTypeChange}
/>
{shouldShowAdvancedBuilder && (
<AdvancedChartBuilder
environmentId={environmentId}
initialChartType={selectedChartType || chartData?.chartType || ""}
initialQuery={chartData?.query as TCubeQuery | undefined}
hidePreview={true}
onChartGenerated={handleAdvancedChartGenerated}
onSave={onAdvancedBuilderSave}
onAddToDashboard={onAdvancedBuilderAddToDashboard}
/>
)}
{chartData && <ChartPreview chartData={chartData} />}
</div>
</DialogBody>
{chartData && (
<ChartDialogFooterWithModals
chartName={chartName}
onChartNameChange={onChartNameChange}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={onDashboardSelect}
onAddToDashboard={onAddToDashboard}
onSave={onSave}
isSaving={isSaving}
isSaveDialogOpen={isSaveDialogOpen}
onSaveDialogOpenChange={onSaveDialogOpenChange}
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
onAddToDashboardDialogOpenChange={onAddToDashboardDialogOpenChange}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,93 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { TCubeQuery } from "../../types/analysis";
import { AdvancedChartBuilder } from "./chart-builder/advanced-chart-builder";
import { ChartPreview } from "./chart-builder/chart-preview";
import { ChartDialogFooterWithModals } from "./chart-dialog-footer-with-modals";
interface EditChartViewProps {
open: boolean;
onClose: () => void;
environmentId: string;
chartData: AnalyticsResponse;
chartName: string;
onChartNameChange: (name: string) => void;
onChartGenerated: (data: AnalyticsResponse) => void;
onAdvancedBuilderSave: (savedChartId: string) => void;
onAdvancedBuilderAddToDashboard: (savedChartId: string, dashboardId?: string) => void;
dashboards: Array<{ id: string; name: string }>;
selectedDashboardId: string;
onDashboardSelect: (id: string) => void;
onAddToDashboard: () => void;
onSave: () => void;
isSaving: boolean;
isSaveDialogOpen: boolean;
onSaveDialogOpenChange: (open: boolean) => void;
isAddToDashboardDialogOpen: boolean;
onAddToDashboardDialogOpenChange: (open: boolean) => void;
}
export function EditChartView({
open,
onClose,
environmentId,
chartData,
chartName,
onChartNameChange,
onChartGenerated,
onAdvancedBuilderSave,
onAdvancedBuilderAddToDashboard,
dashboards,
selectedDashboardId,
onDashboardSelect,
onAddToDashboard,
onSave,
isSaving,
isSaveDialogOpen,
onSaveDialogOpenChange,
isAddToDashboardDialogOpen,
onAddToDashboardDialogOpenChange,
}: Readonly<EditChartViewProps>) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-7xl">
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.edit_chart_title")}</DialogTitle>
<DialogDescription>{t("environments.analysis.charts.edit_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4 px-1">
<AdvancedChartBuilder
environmentId={environmentId}
initialChartType={chartData.chartType || ""}
initialQuery={chartData.query as TCubeQuery | undefined}
hidePreview={true}
onChartGenerated={onChartGenerated}
onSave={onAdvancedBuilderSave}
onAddToDashboard={onAdvancedBuilderAddToDashboard}
/>
<ChartPreview chartData={chartData} />
</div>
</DialogBody>
<ChartDialogFooterWithModals
chartName={chartName}
onChartNameChange={onChartNameChange}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={onDashboardSelect}
onAddToDashboard={onAddToDashboard}
onSave={onSave}
isSaving={isSaving}
isSaveDialogOpen={isSaveDialogOpen}
onSaveDialogOpenChange={onSaveDialogOpenChange}
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
onAddToDashboardDialogOpenChange={onAddToDashboardDialogOpenChange}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,279 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import {
createChartAction,
executeQueryAction,
getChartAction,
updateChartAction,
} from "../actions";
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
import { mapChartType, mapDatabaseChartTypeToApi } from "../lib/chart-utils";
export interface UseCreateChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
defaultDashboardId?: string;
onSuccess?: () => void;
}
export function useCreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
defaultDashboardId,
onSuccess,
}: UseCreateChartDialogProps) {
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>(defaultDashboardId ?? "");
const [isSaving, setIsSaving] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
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);
const queryResult = await executeQueryAction({
environmentId,
query: chart.query as AnalyticsResponse["query"],
});
if (queryResult?.data?.error || queryResult?.serverError) {
toast.error(queryResult.data?.error || queryResult.serverError || "Failed to load chart data");
setIsLoadingChart(false);
return;
}
if (queryResult?.data?.data) {
const loadedChartData: AnalyticsResponse = {
query: chart.query as AnalyticsResponse["query"],
chartType: mapDatabaseChartTypeToApi(chart.type),
data: Array.isArray(queryResult.data.data) ? queryResult.data.data : [],
};
setChartData(loadedChartData);
setCurrentChartId(chart.id);
} else {
toast.error("No data returned for chart");
}
} else if (result?.serverError) {
toast.error(result.serverError);
}
setIsLoadingChart(false);
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : "Failed to load chart";
toast.error(message);
setIsLoadingChart(false);
});
} else if (open && !chartId) {
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
}
}, [open, chartId, environmentId]);
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
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 (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: unknown) {
const message = error instanceof Error ? error.message : "Failed to save chart";
toast.error(message);
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error("Please select a dashboard");
return;
}
setIsSaving(true);
try {
let chartIdToUse = currentChartId;
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);
}
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: unknown) {
const message = error instanceof Error ? error.message : "Failed to add chart to dashboard";
toast.error(message);
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
onOpenChange(false);
}
};
const handleAdvancedBuilderSave = (savedChartId: string) => {
setCurrentChartId(savedChartId);
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
};
const handleAdvancedBuilderAddToDashboard = (savedChartId: string, _dashboardId?: string) => {
setCurrentChartId(savedChartId);
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
onSuccess?.();
};
return {
chartData,
chartName,
setChartName,
selectedChartType,
setSelectedChartType,
currentChartId,
setCurrentChartId,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
isSaving,
isLoadingChart,
shouldShowAdvancedBuilder,
handleChartGenerated,
handleSaveChart,
handleAddToDashboard,
handleClose,
handleAdvancedBuilderSave,
handleAdvancedBuilderAddToDashboard,
};
}

View File

@@ -1,26 +0,0 @@
import type React from "react";
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;
export const CHART_TYPE_ICONS = Object.fromEntries(
CHART_TYPES.map(({ id, icon }) => [id, icon])
) as Record<string, React.ComponentType<{ className?: string }>>;

View File

@@ -1,34 +0,0 @@
import { TApiChartType, TChartType } from "../../types/analysis";
/**
* Map API chart type (used in AnalyticsResponse) to database chart type (Prisma enum).
*/
export const mapChartType = (apiType: string): TChartType => {
const mapping: Record<string, TChartType> = {
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 (for rendering).
*/
export const mapDatabaseChartTypeToApi = (dbType: string): TApiChartType => {
const mapping: Record<string, TApiChartType> = {
bar: "bar",
line: "line",
area: "area",
pie: "pie",
big_number: "kpi",
big_number_total: "kpi",
table: "bar",
funnel: "bar",
map: "bar",
};
return mapping[dbType] || "bar";
};

View File

@@ -1,45 +0,0 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TChart, TChartType } from "../../types/analysis";
/**
* 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,
},
},
},
});
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]));
return charts.map((chart) => {
const createdByName = chart.createdBy ? userMap.get(chart.createdBy) : undefined;
return {
id: chart.id,
name: chart.name,
type: chart.type as TChartType,
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, unknown>) || {},
};
});
});

View File

@@ -1,13 +0,0 @@
import { ChartsList } from "../components/charts-list";
import { getCharts } from "../lib/data";
interface ChartsListPageProps {
params: Promise<{ environmentId: string }>;
}
export async function ChartsListPage({ params }: Readonly<ChartsListPageProps>) {
const { environmentId } = await params;
const charts = await getCharts(environmentId);
return <ChartsList charts={charts} environmentId={environmentId} />;
}

View File

@@ -1,40 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { use } from "react";
import { AnalysisPageLayout } from "./analysis-page-layout";
import { CreateChartButton } from "../charts/components/create-chart-button";
import { CreateDashboardButton } from "../dashboards/components/create-dashboard-button";
interface AnalysisLayoutClientProps {
children: React.ReactNode;
params: Promise<{ environmentId: string }>;
}
export function AnalysisLayoutClient({ children, params }: AnalysisLayoutClientProps) {
const pathname = usePathname();
const { environmentId } = use(params);
let activeId = "dashboards";
if (pathname?.includes("/charts")) {
activeId = "charts";
} else if (pathname?.includes("/dashboards")) {
activeId = "dashboards";
}
const isDashboardsPage = pathname?.endsWith("/dashboards");
const isChartsPage = pathname?.includes("/charts");
let cta;
if (isDashboardsPage) {
cta = <CreateDashboardButton environmentId={environmentId} />;
} else if (isChartsPage) {
cta = <CreateChartButton environmentId={environmentId} />;
}
return (
<AnalysisPageLayout pageTitle="Analysis" activeId={activeId} environmentId={environmentId} cta={cta}>
{children}
</AnalysisPageLayout>
);
}

View File

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

View File

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

View File

@@ -1,316 +0,0 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
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 { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ZDashboardStatus, ZWidgetLayout } from "../types/analysis";
const ZAddChartToDashboardAction = z.object({
environmentId: ZId,
chartId: ZId,
dashboardId: ZId,
title: z.string().optional(),
layout: ZWidgetLayout.optional().default({ x: 0, y: 0, w: 4, h: 3 }),
});
export const addChartToDashboardAction = authenticatedActionClient.schema(ZAddChartToDashboardAction).action(
withAuditLogging(
"created",
"dashboardWidget",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
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,
},
],
});
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");
}
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: AuthenticatedActionClientCtx;
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: ZDashboardStatus.optional(),
});
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
withAuditLogging(
"updated",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
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,
},
],
});
const dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
const updatedDashboard = await prisma.dashboard.update({
where: { id: parsedInput.dashboardId },
data: {
...(parsedInput.name !== undefined && { name: parsedInput.name }),
...(parsedInput.description !== undefined && { description: parsedInput.description }),
...(parsedInput.status !== undefined && { status: parsedInput.status }),
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = dashboard;
ctx.auditLoggingCtx.newObject = updatedDashboard;
return updatedDashboard;
}
)
);
const ZDeleteDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDashboardAction).action(
withAuditLogging(
"deleted",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteDashboardAction>;
}) => {
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.findFirst({
where: { id: parsedInput.dashboardId, projectId },
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
await prisma.dashboardWidget.deleteMany({
where: { dashboardId: parsedInput.dashboardId },
});
await prisma.dashboard.delete({
where: { id: parsedInput.dashboardId },
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = dashboard;
return { success: true };
}
)
);
const ZGetDashboardsAction = z.object({
environmentId: ZId,
});
export const getDashboardsAction = authenticatedActionClient
.schema(ZGetDashboardsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
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;
}
);

View File

@@ -1,76 +0,0 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/modules/ui/components/button";
import { createDashboardAction } from "../actions";
import { CreateDashboardDialog } from "./create-dashboard-dialog";
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("");
router.push(`/environments/${environmentId}/analysis/dashboards/${result.data.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create dashboard";
toast.error(message);
} finally {
setIsCreating(false);
}
};
return (
<>
<Button onClick={handleCreateDashboard}>
<PlusIcon className="mr-2 h-4 w-4" />
Create Dashboard
</Button>
<CreateDashboardDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
dashboardName={dashboardName}
onDashboardNameChange={setDashboardName}
dashboardDescription={dashboardDescription}
onDashboardDescriptionChange={setDashboardDescription}
onCreate={handleCreate}
isCreating={isCreating}
/>
</>
);
}

View File

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

View File

@@ -1,124 +0,0 @@
"use client";
import { CopyIcon, PencilIcon, PlusIcon, 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 { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
import { deleteDashboardAction } from "../actions";
import { TDashboard } from "@/modules/ee/analysis/types/analysis";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { EditDashboardDialog } from "./edit-dashboard-dialog";
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 [isAddChartDialogOpen, setIsAddChartDialogOpen] = useState(false);
const handleDeleteDashboard = async () => {
setIsDeleting(true);
try {
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");
}
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete dashboard";
toast.error(message);
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
}
};
const handleDuplicate = async () => {
toast.success("Duplicate functionality coming soon");
};
const iconActions = [
{
icon: PlusIcon,
tooltip: t("common.add_chart"),
onClick: () => {
setIsAddChartDialogOpen(true);
},
isVisible: true,
},
{
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();
}}
/>
<CreateChartDialog
open={isAddChartDialogOpen}
onOpenChange={setIsAddChartDialogOpen}
environmentId={environmentId}
onSuccess={() => {
setIsAddChartDialogOpen(false);
router.refresh();
}}
/>
</>
);
};

View File

@@ -1,123 +0,0 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { deleteDashboardAction } from "../actions";
import { TDashboard } from "../../types/analysis";
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 {
const result = await deleteDashboardAction({ environmentId, dashboardId });
if (result?.data) {
deleteDashboard(dashboardId);
toast.success("Dashboard deleted successfully");
} else {
toast.error(result?.serverError || "Failed to delete dashboard");
}
} 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/dashboards/${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);
toast.success("Duplicate functionality coming soon");
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Dashboard"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteDashboard(dashboard.id)}
text="Are you sure you want to delete this dashboard? This action cannot be undone."
isDeleting={loading}
/>
</div>
);
};

View File

@@ -1,30 +0,0 @@
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-builder/chart-renderer";
interface DashboardWidgetDataProps {
dataPromise: Promise<{ data: Record<string, unknown>[] } | { error: string }>;
chartType: string;
}
export async function DashboardWidgetData({ dataPromise, chartType }: DashboardWidgetDataProps) {
const result = await dataPromise;
if ("error" in result) {
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">&times;</span>
</div>
Data error
</div>
<p className="text-xs text-red-600">{result.error}</p>
</div>
);
}
if (!result.data || result.data.length === 0) {
return <div className="flex h-full items-center justify-center text-gray-500">No data available</div>;
}
return <ChartRenderer chartType={chartType} data={result.data} />;
}

View File

@@ -1,13 +0,0 @@
export function DashboardWidgetSkeleton() {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<div className="h-40 w-full animate-pulse rounded bg-gray-100" />
<div className="flex w-full justify-between">
<div className="h-3 w-12 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-12 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-12 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-12 animate-pulse rounded bg-gray-100" />
</div>
</div>
);
}

View File

@@ -1,17 +0,0 @@
import { ReactNode } from "react";
interface DashboardWidgetProps {
title: string;
children: ReactNode;
}
export function DashboardWidget({ title, children }: DashboardWidgetProps) {
return (
<div className="flex h-full flex-col rounded-sm border border-gray-200 bg-white shadow-sm ring-1 ring-black/5">
<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">{title}</h3>
</div>
<div className="relative min-h-[300px] flex-1 p-4">{children}</div>
</div>
);
}

View File

@@ -1,97 +0,0 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { BarChart3Icon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { TDashboard } from "../../types/analysis";
import { DashboardDropdownMenu } from "./dashboard-dropdown-menu";
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={`/environments/${environmentId}/analysis/dashboards/${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 whitespace-nowrap text-center text-sm 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 whitespace-nowrap text-center text-sm 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 whitespace-normal text-center text-sm 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>
);
}

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