mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 03:20:43 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08a4607e31 | |||
| 2a89766500 | |||
| 96d87cff36 | |||
| b03eaf03b1 | |||
| d50c8fb401 | |||
| 9a40c0628f | |||
| 72eb7532aa | |||
| 146ac28649 | |||
| 9804851ccc | |||
| 0875c25d47 | |||
| e1bf65871e | |||
| 36955ddbb8 | |||
| ef56e97c95 | |||
| ea3b4b9413 | |||
| 131a04b77c | |||
| ca7e2c64de | |||
| e4bd9a839a |
@@ -229,5 +229,16 @@ REDIS_URL=redis://localhost:6379
|
|||||||
# AUDIT_LOG_GET_USER_IP=0
|
# 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
|
# Lingo.dev API key for translation generation
|
||||||
LINGODOTDEV_API_KEY=your_api_key_here
|
LINGODOTDEV_API_KEY=your_api_key_here
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
|
||||||
|
|
||||||
|
export default function ChartsListLoading() {
|
||||||
|
return <ChartsListSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ChartsListPage as default } from "@/modules/ee/analysis/charts/pages/charts-list-page";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||||
|
|
||||||
|
export default DashboardsListPage;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
|
||||||
|
|
||||||
|
export default DashboardDetailPage;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function AnalysisPage({ params }: { params: Promise<{ environmentId: string }> }) {
|
||||||
|
const { environmentId } = await params;
|
||||||
|
if (!environmentId || environmentId === "undefined") {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
redirect(`/environments/${environmentId}/analysis/dashboards`);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MessageCircle,
|
MessageCircle,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
|
PieChart,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
@@ -105,6 +106,13 @@ export const MainNavigation = ({
|
|||||||
isActive: pathname?.includes("/surveys"),
|
isActive: pathname?.includes("/surveys"),
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: t("common.analysis"),
|
||||||
|
href: `/environments/${environment.id}/analysis`,
|
||||||
|
icon: PieChart,
|
||||||
|
isActive: pathname?.includes("/analysis"),
|
||||||
|
isHidden: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: `/environments/${environment.id}/contacts`,
|
href: `/environments/${environment.id}/contacts`,
|
||||||
name: t("common.contacts"),
|
name: t("common.contacts"),
|
||||||
@@ -188,7 +196,7 @@ export const MainNavigation = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||||
)}>
|
)}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ const EnvLayout = async (props: {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
const { environmentId } = params;
|
||||||
|
|
||||||
|
if (!environmentId || environmentId === "undefined") {
|
||||||
|
return redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
// Check session first (required for userId)
|
// Check session first (required for userId)
|
||||||
|
|||||||
+1
-1
@@ -280,7 +280,7 @@ export const AddIntegrationModal = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((question) => (
|
{surveyElements.map((question) => (
|
||||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* TypeScript types for the Analytics API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TimeDimension {
|
||||||
|
dimension: string;
|
||||||
|
granularity?: "hour" | "day" | "week" | "month" | "quarter" | "year";
|
||||||
|
dateRange?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filter {
|
||||||
|
member: string;
|
||||||
|
operator:
|
||||||
|
| "equals"
|
||||||
|
| "notEquals"
|
||||||
|
| "contains"
|
||||||
|
| "notContains"
|
||||||
|
| "set"
|
||||||
|
| "notSet"
|
||||||
|
| "gt"
|
||||||
|
| "gte"
|
||||||
|
| "lt"
|
||||||
|
| "lte";
|
||||||
|
values?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CubeQuery {
|
||||||
|
measures: string[];
|
||||||
|
dimensions?: string[];
|
||||||
|
timeDimensions?: TimeDimension[];
|
||||||
|
filters?: Filter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsResponse {
|
||||||
|
query: CubeQuery;
|
||||||
|
chartType: "bar" | "line" | "donut" | "kpi" | "area" | "pie";
|
||||||
|
data?: Record<string, any>[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
cube(`FeedbackRecords`, {
|
||||||
|
sql: `SELECT * FROM feedback_records`,
|
||||||
|
|
||||||
|
measures: {
|
||||||
|
count: {
|
||||||
|
type: `count`,
|
||||||
|
description: `Total number of feedback responses`,
|
||||||
|
},
|
||||||
|
|
||||||
|
promoterCount: {
|
||||||
|
type: `count`,
|
||||||
|
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||||
|
description: `Number of promoters (NPS score 9-10)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
detractorCount: {
|
||||||
|
type: `count`,
|
||||||
|
filters: [{ sql: `${CUBE}.value_number <= 6` }],
|
||||||
|
description: `Number of detractors (NPS score 0-6)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
passiveCount: {
|
||||||
|
type: `count`,
|
||||||
|
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||||
|
description: `Number of passives (NPS score 7-8)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
npsScore: {
|
||||||
|
type: `number`,
|
||||||
|
sql: `
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) = 0 THEN 0
|
||||||
|
ELSE ROUND(
|
||||||
|
(
|
||||||
|
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||||
|
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||||
|
/ COUNT(*)::numeric
|
||||||
|
) * 100,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
END
|
||||||
|
`,
|
||||||
|
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||||
|
},
|
||||||
|
|
||||||
|
averageScore: {
|
||||||
|
type: `avg`,
|
||||||
|
sql: `${CUBE}.value_number`,
|
||||||
|
description: `Average NPS score`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
dimensions: {
|
||||||
|
id: {
|
||||||
|
sql: `id`,
|
||||||
|
type: `string`,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
sentiment: {
|
||||||
|
sql: `sentiment`,
|
||||||
|
type: `string`,
|
||||||
|
description: `Sentiment extracted from metadata JSONB field`,
|
||||||
|
},
|
||||||
|
|
||||||
|
sourceType: {
|
||||||
|
sql: `source_type`,
|
||||||
|
type: `string`,
|
||||||
|
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
sourceName: {
|
||||||
|
sql: `source_name`,
|
||||||
|
type: `string`,
|
||||||
|
description: `Human-readable name of the source`,
|
||||||
|
},
|
||||||
|
|
||||||
|
fieldType: {
|
||||||
|
sql: `field_type`,
|
||||||
|
type: `string`,
|
||||||
|
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
collectedAt: {
|
||||||
|
sql: `collected_at`,
|
||||||
|
type: `time`,
|
||||||
|
description: `Timestamp when the feedback was collected`,
|
||||||
|
},
|
||||||
|
|
||||||
|
npsValue: {
|
||||||
|
sql: `value_number`,
|
||||||
|
type: `number`,
|
||||||
|
description: `Raw NPS score value (0-10)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
responseId: {
|
||||||
|
sql: `response_id`,
|
||||||
|
type: `string`,
|
||||||
|
description: `Unique identifier linking related feedback records`,
|
||||||
|
},
|
||||||
|
|
||||||
|
userIdentifier: {
|
||||||
|
sql: `user_identifier`,
|
||||||
|
type: `string`,
|
||||||
|
description: `Identifier of the user who provided feedback`,
|
||||||
|
},
|
||||||
|
|
||||||
|
emotion: {
|
||||||
|
sql: `emotion`,
|
||||||
|
type: `string`,
|
||||||
|
description: `Emotion extracted from metadata JSONB field`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
joins: {
|
||||||
|
TopicsUnnested: {
|
||||||
|
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
|
||||||
|
relationship: `hasMany`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cube(`TopicsUnnested`, {
|
||||||
|
sql: `
|
||||||
|
SELECT
|
||||||
|
fr.id as feedback_record_id,
|
||||||
|
topic_elem.topic
|
||||||
|
FROM feedback_records fr
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
|
||||||
|
`,
|
||||||
|
|
||||||
|
measures: {
|
||||||
|
count: {
|
||||||
|
type: `count`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
dimensions: {
|
||||||
|
id: {
|
||||||
|
sql: `feedback_record_id || '-' || topic`,
|
||||||
|
type: `string`,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
feedbackRecordId: {
|
||||||
|
sql: `feedback_record_id`,
|
||||||
|
type: `string`,
|
||||||
|
},
|
||||||
|
|
||||||
|
topic: {
|
||||||
|
sql: `topic`,
|
||||||
|
type: `string`,
|
||||||
|
description: `Individual topic from the topics array`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ checksums:
|
|||||||
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
||||||
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
||||||
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
||||||
|
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
|
||||||
common/and: dc75b95c804b16dc617a5f16f7393bca
|
common/and: dc75b95c804b16dc617a5f16f7393bca
|
||||||
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
|
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
|
||||||
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
|
|||||||
quotaId?: string;
|
quotaId?: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
integrationId?: string;
|
integrationId?: string;
|
||||||
|
chartId?: string;
|
||||||
|
dashboardId?: string;
|
||||||
|
dashboardWidgetId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActionClientCtx = {
|
export type ActionClientCtx = {
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"allow": "erlauben",
|
"allow": "erlauben",
|
||||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
|
"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",
|
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
|
||||||
|
"analysis": "Analyse",
|
||||||
"and": "und",
|
"and": "und",
|
||||||
"and_response_limit_of": "und Antwortlimit von",
|
"and_response_limit_of": "und Antwortlimit von",
|
||||||
"anonymous": "Anonym",
|
"anonymous": "Anonym",
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
"activity": "Activity",
|
"activity": "Activity",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"add_action": "Add action",
|
"add_action": "Add action",
|
||||||
|
"add_chart": "Add chart",
|
||||||
"add_filter": "Add filter",
|
"add_filter": "Add filter",
|
||||||
"add_logo": "Add logo",
|
"add_logo": "Add logo",
|
||||||
"add_member": "Add member",
|
"add_member": "Add member",
|
||||||
@@ -133,6 +134,7 @@
|
|||||||
"allow": "Allow",
|
"allow": "Allow",
|
||||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
|
"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",
|
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
|
||||||
|
"analysis": "Analysis",
|
||||||
"and": "And",
|
"and": "And",
|
||||||
"and_response_limit_of": "and response limit of",
|
"and_response_limit_of": "and response limit of",
|
||||||
"anonymous": "Anonymous",
|
"anonymous": "Anonymous",
|
||||||
@@ -399,6 +401,7 @@
|
|||||||
"styling": "Styling",
|
"styling": "Styling",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
|
"chart": "Chart",
|
||||||
"survey": "Survey",
|
"survey": "Survey",
|
||||||
"survey_completed": "Survey completed.",
|
"survey_completed": "Survey completed.",
|
||||||
"survey_id": "Survey ID",
|
"survey_id": "Survey ID",
|
||||||
@@ -618,6 +621,17 @@
|
|||||||
"subtitle": "It takes less than 4 minutes.",
|
"subtitle": "It takes less than 4 minutes.",
|
||||||
"waiting_for_your_signal": "Waiting for your signal…"
|
"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": {
|
"contacts": {
|
||||||
"add_attribute": "Add Attribute",
|
"add_attribute": "Add Attribute",
|
||||||
"attribute_created_successfully": "Attribute created successfully",
|
"attribute_created_successfully": "Attribute created successfully",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"allow": "Permitir",
|
"allow": "Permitir",
|
||||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
|
"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",
|
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
|
||||||
|
"analysis": "Análisis",
|
||||||
"and": "Y",
|
"and": "Y",
|
||||||
"and_response_limit_of": "y límite de respuesta de",
|
"and_response_limit_of": "y límite de respuesta de",
|
||||||
"anonymous": "Anónimo",
|
"anonymous": "Anónimo",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"allow": "Autoriser",
|
"allow": "Autoriser",
|
||||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
|
"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",
|
"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": "Et",
|
||||||
"and_response_limit_of": "et limite de réponse de",
|
"and_response_limit_of": "et limite de réponse de",
|
||||||
"anonymous": "Anonyme",
|
"anonymous": "Anonyme",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"allow": "Engedélyezés",
|
"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",
|
"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",
|
"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": "És",
|
||||||
"and_response_limit_of": "és kérdéskorlátja ennek:",
|
"and_response_limit_of": "és kérdéskorlátja ennek:",
|
||||||
"anonymous": "Névtelen",
|
"anonymous": "Névtelen",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"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": "{type}の削除中に不明なエラーが発生しました",
|
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
|
||||||
|
"analysis": "分析",
|
||||||
"and": "および",
|
"and": "および",
|
||||||
"and_response_limit_of": "と回答数の上限",
|
"and_response_limit_of": "と回答数の上限",
|
||||||
"anonymous": "匿名",
|
"anonymous": "匿名",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"allow": "Toestaan",
|
"allow": "Toestaan",
|
||||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
|
"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",
|
"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": "En",
|
||||||
"and_response_limit_of": "en responslimiet van",
|
"and_response_limit_of": "en responslimiet van",
|
||||||
"anonymous": "Anoniem",
|
"anonymous": "Anoniem",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"allow": "permitir",
|
"allow": "permitir",
|
||||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
|
"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",
|
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
|
||||||
|
"analysis": "Análise",
|
||||||
"and": "E",
|
"and": "E",
|
||||||
"and_response_limit_of": "e limite de resposta de",
|
"and_response_limit_of": "e limite de resposta de",
|
||||||
"anonymous": "Anônimo",
|
"anonymous": "Anônimo",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"allow": "Permitir",
|
"allow": "Permitir",
|
||||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
|
"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",
|
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
|
||||||
|
"analysis": "Análise",
|
||||||
"and": "E",
|
"and": "E",
|
||||||
"and_response_limit_of": "e limite de resposta de",
|
"and_response_limit_of": "e limite de resposta de",
|
||||||
"anonymous": "Anónimo",
|
"anonymous": "Anónimo",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"allow": "Permite",
|
"allow": "Permite",
|
||||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
|
"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}",
|
"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": "Și",
|
||||||
"and_response_limit_of": "și limită răspuns",
|
"and_response_limit_of": "și limită răspuns",
|
||||||
"anonymous": "Anonim",
|
"anonymous": "Anonim",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"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": "Произошла неизвестная ошибка при удалении {type}ов",
|
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
|
||||||
|
"analysis": "Анализ",
|
||||||
"and": "и",
|
"and": "и",
|
||||||
"and_response_limit_of": "и лимит ответов",
|
"and_response_limit_of": "и лимит ответов",
|
||||||
"anonymous": "Аноним",
|
"anonymous": "Аноним",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"allow": "Tillåt",
|
"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",
|
"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}",
|
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
|
||||||
|
"analysis": "Analys",
|
||||||
"and": "Och",
|
"and": "Och",
|
||||||
"and_response_limit_of": "och svarsgräns på",
|
"and_response_limit_of": "och svarsgräns på",
|
||||||
"anonymous": "Anonym",
|
"anonymous": "Anonym",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"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": "删除 {type} 时发生未知错误",
|
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
|
||||||
|
"analysis": "分析",
|
||||||
"and": "和",
|
"and": "和",
|
||||||
"and_response_limit_of": "和 响应限制",
|
"and_response_limit_of": "和 响应限制",
|
||||||
"anonymous": "匿名",
|
"anonymous": "匿名",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"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": "刪除 '{'type'}' 時發生未知錯誤",
|
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
|
||||||
|
"analysis": "分析",
|
||||||
"and": "且",
|
"and": "且",
|
||||||
"and_response_limit_of": "且回應上限為",
|
"and_response_limit_of": "且回應上限為",
|
||||||
"anonymous": "匿名",
|
"anonymous": "匿名",
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ export const SingleResponseCardBody = ({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
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">
|
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">
|
||||||
@{part}
|
@{part}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
|
||||||
|
interface Dashboard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddToDashboardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
chartName: string;
|
||||||
|
onChartNameChange: (name: string) => void;
|
||||||
|
dashboards: Dashboard[];
|
||||||
|
selectedDashboardId: string;
|
||||||
|
onDashboardSelect: (id: string) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddToDashboardDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
chartName,
|
||||||
|
onChartNameChange,
|
||||||
|
dashboards,
|
||||||
|
selectedDashboardId,
|
||||||
|
onDashboardSelect,
|
||||||
|
onAdd,
|
||||||
|
isSaving,
|
||||||
|
}: AddToDashboardDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Chart to Dashboard</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a dashboard to add this chart to. The chart will be saved automatically.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="chart-name" className="mb-2 block text-sm font-medium text-gray-700">
|
||||||
|
Chart Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="chart-name"
|
||||||
|
placeholder="Chart name"
|
||||||
|
value={chartName}
|
||||||
|
onChange={(e) => onChartNameChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dashboard-select" className="mb-2 block text-sm font-medium text-gray-700">
|
||||||
|
Dashboard
|
||||||
|
</label>
|
||||||
|
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
|
||||||
|
<SelectTrigger id="dashboard-select" className="w-full bg-white">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={dashboards.length === 0 ? "No dashboards available" : "Select a dashboard"}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper" className="z-[100] max-h-[200px]">
|
||||||
|
{dashboards.length === 0 ? (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-gray-500">No dashboards available</div>
|
||||||
|
) : (
|
||||||
|
dashboards.map((dashboard) => (
|
||||||
|
<SelectItem key={dashboard.id} value={dashboard.id}>
|
||||||
|
{dashboard.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{dashboards.length === 0 && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Create a dashboard first to add charts to it.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onAdd} loading={isSaving} disabled={!selectedDashboardId}>
|
||||||
|
Add to Dashboard
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
+639
@@ -0,0 +1,639 @@
|
|||||||
|
"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 "Run Query" to preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialogs - Only render when callbacks are not provided (standalone mode) */}
|
||||||
|
{!onSave && (
|
||||||
|
<SaveChartDialog
|
||||||
|
open={isSaveDialogOpen}
|
||||||
|
onOpenChange={setIsSaveDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
onSave={handleSaveChart}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!onAddToDashboard && (
|
||||||
|
<AddToDashboardDialog
|
||||||
|
open={isAddToDashboardDialogOpen}
|
||||||
|
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
dashboards={dashboards}
|
||||||
|
selectedDashboardId={selectedDashboardId}
|
||||||
|
onDashboardSelect={setSelectedDashboardId}
|
||||||
|
onAdd={handleAddToDashboard}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
|
||||||
|
|
||||||
|
// Formbricks brand colors
|
||||||
|
const BRAND_DARK = "#00C4B8";
|
||||||
|
const BRAND_LIGHT = "#00E6CA";
|
||||||
|
|
||||||
|
interface ChartRendererProps {
|
||||||
|
chartType: string;
|
||||||
|
data: Record<string, 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 "{chartType}" not yet supported
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { CHART_TYPES } from "../../lib/chart-types";
|
||||||
|
|
||||||
|
interface ConfigureChartDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
currentChartType: string;
|
||||||
|
configuredChartType: string | null;
|
||||||
|
onChartTypeSelect: (type: string) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigureChartDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
currentChartType,
|
||||||
|
configuredChartType,
|
||||||
|
onChartTypeSelect,
|
||||||
|
onReset,
|
||||||
|
}: ConfigureChartDialogProps) {
|
||||||
|
const availableTypes = CHART_TYPES.filter((type) =>
|
||||||
|
["bar", "line", "area", "pie", "big_number"].includes(type.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configure Chart</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Modify the chart type and other settings for this visualization.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 text-sm font-medium text-gray-900">Chart Type</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||||
|
{availableTypes.map((chart) => {
|
||||||
|
const isSelected = (configuredChartType || currentChartType) === chart.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={chart.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChartTypeSelect(chart.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-all hover:bg-gray-50",
|
||||||
|
isSelected
|
||||||
|
? "border-brand-dark bg-brand-dark/5 ring-brand-dark ring-2"
|
||||||
|
: "border-gray-200"
|
||||||
|
)}>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded bg-gray-100">
|
||||||
|
<chart.icon className="h-5 w-5 text-gray-600" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onReset} className="text-xs">
|
||||||
|
Reset to AI suggestion
|
||||||
|
</Button>
|
||||||
|
{configuredChartType && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Original: {CHART_TYPES.find((t) => t.id === currentChartType)?.name || currentChartType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Apply Changes</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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 }>>;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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";
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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>) || {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
|
||||||
|
|
||||||
|
interface AnalysisPageLayoutProps {
|
||||||
|
pageTitle: string;
|
||||||
|
activeId: string;
|
||||||
|
environmentId: string;
|
||||||
|
cta?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalysisPageLayout({
|
||||||
|
pageTitle,
|
||||||
|
activeId,
|
||||||
|
environmentId,
|
||||||
|
cta,
|
||||||
|
children,
|
||||||
|
}: AnalysisPageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle={pageTitle} cta={cta}>
|
||||||
|
<AnalysisSecondaryNavigation activeId={activeId} environmentId={environmentId} />
|
||||||
|
</PageHeader>
|
||||||
|
{children}
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||||
|
|
||||||
|
interface AnalysisSecondaryNavigationProps {
|
||||||
|
activeId: string;
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalysisSecondaryNavigation({ activeId, environmentId }: AnalysisSecondaryNavigationProps) {
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
id: "dashboards",
|
||||||
|
label: "Dashboards",
|
||||||
|
href: `/environments/${environmentId}/analysis/dashboards`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "charts",
|
||||||
|
label: "Charts",
|
||||||
|
href: `/environments/${environmentId}/analysis/charts`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <SecondaryNavigation navigation={navigation} activeId={activeId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { createDashboardAction } from "../actions";
|
||||||
|
import { CreateDashboardDialog } from "./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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
|
||||||
|
interface CreateDashboardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
dashboardName: string;
|
||||||
|
onDashboardNameChange: (name: string) => void;
|
||||||
|
dashboardDescription: string;
|
||||||
|
onDashboardDescriptionChange: (description: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
isCreating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateDashboardDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
dashboardName,
|
||||||
|
onDashboardNameChange,
|
||||||
|
dashboardDescription,
|
||||||
|
onDashboardDescriptionChange,
|
||||||
|
onCreate,
|
||||||
|
isCreating,
|
||||||
|
}: CreateDashboardDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Dashboard</DialogTitle>
|
||||||
|
<DialogDescription>Enter a name for your dashboard to create it.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="dashboard-name" className="text-sm font-medium text-gray-900">
|
||||||
|
Dashboard Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="dashboard-name"
|
||||||
|
placeholder="Dashboard name"
|
||||||
|
value={dashboardName}
|
||||||
|
onChange={(e) => onDashboardNameChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && dashboardName.trim() && !isCreating) {
|
||||||
|
onCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="dashboard-description" className="text-sm font-medium text-gray-900">
|
||||||
|
Description (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="dashboard-description"
|
||||||
|
placeholder="Dashboard description"
|
||||||
|
value={dashboardDescription}
|
||||||
|
onChange={(e) => onDashboardDescriptionChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onCreate} loading={isCreating} disabled={!dashboardName.trim()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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">×</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} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
import { BarChart3Icon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TDashboard } from "../../types/analysis";
|
||||||
|
import { DashboardDropdownMenu } from "./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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const SKELETON_ROWS = 5;
|
||||||
|
|
||||||
|
function SkeletonRow() {
|
||||||
|
return (
|
||||||
|
<div className="grid h-12 w-full animate-pulse grid-cols-8 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-6 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 DashboardsListSkeleton() {
|
||||||
|
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>
|
||||||
|
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
|
||||||
|
<SkeletonRow key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { updateDashboardAction } from "../actions";
|
||||||
|
|
||||||
|
interface EditDashboardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
dashboardId: string;
|
||||||
|
environmentId: string;
|
||||||
|
initialName: string;
|
||||||
|
initialDescription?: string;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDashboardDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
dashboardId,
|
||||||
|
environmentId,
|
||||||
|
initialName,
|
||||||
|
initialDescription,
|
||||||
|
onSuccess,
|
||||||
|
}: EditDashboardDialogProps) {
|
||||||
|
const [dashboardName, setDashboardName] = useState(initialName);
|
||||||
|
const [dashboardDescription, setDashboardDescription] = useState(initialDescription || "");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDashboardName(initialName);
|
||||||
|
setDashboardDescription(initialDescription || "");
|
||||||
|
}
|
||||||
|
}, [open, initialName, initialDescription]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!dashboardName.trim()) {
|
||||||
|
toast.error("Please enter a dashboard name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await updateDashboardAction({
|
||||||
|
environmentId,
|
||||||
|
dashboardId,
|
||||||
|
name: dashboardName.trim(),
|
||||||
|
description: dashboardDescription.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.data) {
|
||||||
|
toast.error(result?.serverError || "Failed to update dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Dashboard updated successfully!");
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to update dashboard";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Dashboard</DialogTitle>
|
||||||
|
<DialogDescription>Update dashboard name and description.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="edit-dashboard-name" className="text-sm font-medium text-gray-900">
|
||||||
|
Dashboard Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-dashboard-name"
|
||||||
|
placeholder="Dashboard name"
|
||||||
|
value={dashboardName}
|
||||||
|
onChange={(e) => setDashboardName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && dashboardName.trim() && !isSaving) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="edit-dashboard-description" className="text-sm font-medium text-gray-900">
|
||||||
|
Description (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-dashboard-description"
|
||||||
|
placeholder="Dashboard description"
|
||||||
|
value={dashboardDescription}
|
||||||
|
onChange={(e) => setDashboardDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} loading={isSaving} disabled={!dashboardName.trim()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { cache as reactCache } from "react";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { executeQuery } from "@/app/api/analytics/_lib/cube-client";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import {
|
||||||
|
TChartConfig,
|
||||||
|
TChartType,
|
||||||
|
TCubeQuery,
|
||||||
|
TDashboard,
|
||||||
|
TWidgetType,
|
||||||
|
} from "../../types/analysis";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all dashboards for the given environment.
|
||||||
|
*/
|
||||||
|
export const getDashboards = reactCache(async (environmentId: string): Promise<TDashboard[]> => {
|
||||||
|
const { project } = await getEnvironmentAuth(environmentId);
|
||||||
|
|
||||||
|
const dashboards = await prisma.dashboard.findMany({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
widgets: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
title: true,
|
||||||
|
chartId: true,
|
||||||
|
layout: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userIds = [...new Set(dashboards.map((d) => d.createdBy).filter(Boolean) as string[])];
|
||||||
|
const users = await Promise.all(userIds.map((id) => getUser(id)));
|
||||||
|
const userMap = new Map(users.filter(Boolean).map((u) => [u!.id, u!.name]));
|
||||||
|
|
||||||
|
return dashboards.map((dashboard) => {
|
||||||
|
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
|
||||||
|
const createdByName = dashboard.createdBy ? userMap.get(dashboard.createdBy) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dashboard.id,
|
||||||
|
name: dashboard.name,
|
||||||
|
description: dashboard.description || undefined,
|
||||||
|
status: dashboard.status,
|
||||||
|
lastModified: dashboard.updatedAt.toISOString(),
|
||||||
|
createdAt: dashboard.createdAt.toISOString(),
|
||||||
|
updatedAt: dashboard.updatedAt.toISOString(),
|
||||||
|
createdBy: dashboard.createdBy || undefined,
|
||||||
|
createdByName,
|
||||||
|
chartCount,
|
||||||
|
widgets: dashboard.widgets.map((widget) => ({
|
||||||
|
id: widget.id,
|
||||||
|
type: widget.type as TWidgetType,
|
||||||
|
title: widget.title || undefined,
|
||||||
|
chartId: widget.chartId || undefined,
|
||||||
|
layout: widget.layout as { x: number; y: number; w: number; h: number },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a Cube.js query server-side and returns the result rows.
|
||||||
|
* Intended to be called from server components so data is fetched on
|
||||||
|
* the server rather than via client-side useEffect waterfalls.
|
||||||
|
*/
|
||||||
|
export async function executeWidgetQuery(
|
||||||
|
query: TCubeQuery
|
||||||
|
): Promise<{ data: Record<string, unknown>[] } | { error: string }> {
|
||||||
|
try {
|
||||||
|
const data = await executeQuery(query as Record<string, unknown>);
|
||||||
|
return { data: Array.isArray(data) ? data : [] };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to execute query";
|
||||||
|
return { error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a single dashboard by ID.
|
||||||
|
*/
|
||||||
|
export const getDashboard = reactCache(
|
||||||
|
async (environmentId: string, dashboardId: string): Promise<TDashboard | null> => {
|
||||||
|
const { project } = await getEnvironmentAuth(environmentId);
|
||||||
|
|
||||||
|
const dashboard = await prisma.dashboard.findFirst({
|
||||||
|
where: {
|
||||||
|
id: dashboardId,
|
||||||
|
projectId: project.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
widgets: {
|
||||||
|
include: {
|
||||||
|
chart: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
query: true,
|
||||||
|
config: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
order: "asc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dashboard.id,
|
||||||
|
name: dashboard.name,
|
||||||
|
description: dashboard.description || undefined,
|
||||||
|
status: dashboard.status,
|
||||||
|
lastModified: dashboard.updatedAt.toISOString(),
|
||||||
|
createdAt: dashboard.createdAt.toISOString(),
|
||||||
|
updatedAt: dashboard.updatedAt.toISOString(),
|
||||||
|
createdBy: dashboard.createdBy || undefined,
|
||||||
|
createdByName: undefined,
|
||||||
|
chartCount,
|
||||||
|
widgets: dashboard.widgets.map((widget) => ({
|
||||||
|
id: widget.id,
|
||||||
|
type: widget.type as TWidgetType,
|
||||||
|
title: widget.title || undefined,
|
||||||
|
chartId: widget.chartId || undefined,
|
||||||
|
layout: widget.layout as { x: number; y: number; w: number; h: number },
|
||||||
|
chart: widget.chart
|
||||||
|
? {
|
||||||
|
id: widget.chart.id,
|
||||||
|
name: widget.chart.name,
|
||||||
|
type: widget.chart.type as TChartType,
|
||||||
|
query: widget.chart.query as unknown as TCubeQuery,
|
||||||
|
config: ((widget.chart.config as TChartConfig) || {}) as TChartConfig,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Delay } from "@suspensive/react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||||
|
import { DashboardControlBar } from "../components/dashboard-control-bar";
|
||||||
|
import { DashboardWidget } from "../components/dashboard-widget";
|
||||||
|
import { DashboardWidgetData } from "../components/dashboard-widget-data";
|
||||||
|
import { DashboardWidgetSkeleton } from "../components/dashboard-widget-skeleton";
|
||||||
|
import { executeWidgetQuery, getDashboard } from "../lib/data";
|
||||||
|
import { TDashboardWidget } from "../../types/analysis";
|
||||||
|
|
||||||
|
function getColSpan(w: number) {
|
||||||
|
if (w <= 2) return "col-span-12 md:col-span-2";
|
||||||
|
if (w <= 3) return "col-span-12 md:col-span-3";
|
||||||
|
if (w <= 4) return "col-span-12 md:col-span-4";
|
||||||
|
if (w <= 6) return "col-span-12 md:col-span-6";
|
||||||
|
if (w <= 8) return "col-span-12 md:col-span-8";
|
||||||
|
if (w <= 9) return "col-span-12 md:col-span-9";
|
||||||
|
return "col-span-12";
|
||||||
|
}
|
||||||
|
|
||||||
|
function StaticWidgetContent({ widget }: { widget: TDashboardWidget }) {
|
||||||
|
if (widget.type === "markdown") {
|
||||||
|
return (
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
<p className="text-gray-500">Markdown widget placeholder</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.type === "header") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900">{widget.title || "Header"}</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.type === "divider") {
|
||||||
|
return <div className="h-full w-full border-t border-gray-200" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DashboardDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ environmentId: string; dashboardId: string }>;
|
||||||
|
}) {
|
||||||
|
const { environmentId, dashboardId } = await params;
|
||||||
|
const dashboard = await getDashboard(environmentId, dashboardId);
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = dashboard.widgets.length === 0;
|
||||||
|
|
||||||
|
// Kick off all chart data queries in parallel (don't await -- let Suspense stream them)
|
||||||
|
const widgetDataPromises = new Map<
|
||||||
|
string,
|
||||||
|
Promise<{ data: Record<string, unknown>[] } | { error: string }>
|
||||||
|
>();
|
||||||
|
for (const widget of dashboard.widgets) {
|
||||||
|
if (widget.type === "chart" && widget.chart) {
|
||||||
|
widgetDataPromises.set(widget.id, executeWidgetQuery(widget.chart.query));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<GoBackButton url={`/environments/${environmentId}/analysis/dashboards`} />
|
||||||
|
<PageHeader
|
||||||
|
pageTitle={dashboard.name}
|
||||||
|
cta={<DashboardControlBar environmentId={environmentId} dashboard={dashboard} />}>
|
||||||
|
{dashboard.description && <p className="mt-2 text-sm text-gray-500">{dashboard.description}</p>}
|
||||||
|
</PageHeader>
|
||||||
|
<section className="pb-24 pt-6">
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/50">
|
||||||
|
<div className="mb-4 rounded-full bg-gray-100 p-4">
|
||||||
|
<div className="h-12 w-12 rounded-md bg-gray-300 opacity-20" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">No Data</h3>
|
||||||
|
<p className="mt-2 max-w-sm text-center text-gray-500">
|
||||||
|
There is currently no information to display. Add charts to build your dashboard.
|
||||||
|
</p>
|
||||||
|
<CreateChartButton environmentId={environmentId} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-12 gap-6">
|
||||||
|
{dashboard.widgets.map((widget) => (
|
||||||
|
<div key={widget.id} className={getColSpan(widget.layout.w)}>
|
||||||
|
{widget.type === "chart" && widget.chart ? (
|
||||||
|
<DashboardWidget title={widget.title || widget.chart.name || "Widget"}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Delay ms={200}>
|
||||||
|
<DashboardWidgetSkeleton />
|
||||||
|
</Delay>
|
||||||
|
}>
|
||||||
|
<DashboardWidgetData
|
||||||
|
dataPromise={widgetDataPromises.get(widget.id)!}
|
||||||
|
chartType={widget.chart.type}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</DashboardWidget>
|
||||||
|
) : (
|
||||||
|
<DashboardWidget title={widget.title || "Widget"}>
|
||||||
|
<StaticWidgetContent widget={widget} />
|
||||||
|
</DashboardWidget>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { DashboardsListClient } from "../components/dashboards-list-client";
|
||||||
|
import { DashboardsListSkeleton } from "../components/dashboards-list-skeleton";
|
||||||
|
import { getDashboards } from "../lib/data";
|
||||||
|
|
||||||
|
async function DashboardsListContent({ environmentId }: { environmentId: string }) {
|
||||||
|
const dashboards = await getDashboards(environmentId);
|
||||||
|
return <DashboardsListClient dashboards={dashboards} environmentId={environmentId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DashboardsListPage({ params }: { params: Promise<{ environmentId: string }> }) {
|
||||||
|
const { environmentId } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<DashboardsListSkeleton />}>
|
||||||
|
<DashboardsListContent environmentId={environmentId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Query builder utility to construct Cube.js queries from chart builder state.
|
||||||
|
*/
|
||||||
|
import { TCubeFilter, TCubeQuery, TTimeDimension } from "../types/analysis";
|
||||||
|
|
||||||
|
export interface CustomMeasure {
|
||||||
|
field: string;
|
||||||
|
aggregation: string;
|
||||||
|
alias?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterRow {
|
||||||
|
field: string;
|
||||||
|
operator: TCubeFilter["operator"];
|
||||||
|
values: string[] | number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeDimensionConfig {
|
||||||
|
dimension: string;
|
||||||
|
granularity: "hour" | "day" | "week" | "month" | "quarter" | "year";
|
||||||
|
dateRange: string | [Date, Date];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartBuilderState {
|
||||||
|
chartType: string;
|
||||||
|
selectedMeasures: string[];
|
||||||
|
customMeasures: CustomMeasure[];
|
||||||
|
selectedDimensions: string[];
|
||||||
|
filters: FilterRow[];
|
||||||
|
filterLogic: "and" | "or";
|
||||||
|
timeDimension: TimeDimensionConfig | null;
|
||||||
|
limit?: number;
|
||||||
|
orderBy?: { field: string; direction: "asc" | "desc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Cube.js query from chart builder state.
|
||||||
|
*/
|
||||||
|
export function buildCubeQuery(config: ChartBuilderState): TCubeQuery {
|
||||||
|
const query: TCubeQuery = {
|
||||||
|
measures: [...config.selectedMeasures],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.selectedDimensions.length > 0) {
|
||||||
|
query.dimensions = config.selectedDimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.timeDimension) {
|
||||||
|
const timeDim: TTimeDimension = {
|
||||||
|
dimension: config.timeDimension.dimension,
|
||||||
|
granularity: config.timeDimension.granularity,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof config.timeDimension.dateRange === "string") {
|
||||||
|
timeDim.dateRange = config.timeDimension.dateRange;
|
||||||
|
} else if (Array.isArray(config.timeDimension.dateRange)) {
|
||||||
|
const [startDate, endDate] = config.timeDimension.dateRange;
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
|
||||||
|
}
|
||||||
|
|
||||||
|
query.timeDimensions = [timeDim];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.filters.length > 0) {
|
||||||
|
query.filters = config.filters.map((f) => {
|
||||||
|
const filter: TCubeFilter = {
|
||||||
|
member: f.field,
|
||||||
|
operator: f.operator,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (f.operator !== "set" && f.operator !== "notSet" && f.values) {
|
||||||
|
filter.values = f.values.map((v) => String(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Cube.js query back into ChartBuilderState.
|
||||||
|
*/
|
||||||
|
export function parseQueryToState(query: TCubeQuery, chartType?: string): Partial<ChartBuilderState> {
|
||||||
|
const state: Partial<ChartBuilderState> = {
|
||||||
|
chartType: chartType || "",
|
||||||
|
selectedMeasures: query.measures || [],
|
||||||
|
customMeasures: [],
|
||||||
|
selectedDimensions: query.dimensions || [],
|
||||||
|
filters: [],
|
||||||
|
filterLogic: "and",
|
||||||
|
timeDimension: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.filters && query.filters.length > 0) {
|
||||||
|
state.filters = query.filters.map((f) => ({
|
||||||
|
field: f.member,
|
||||||
|
operator: f.operator,
|
||||||
|
values: f.values || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
||||||
|
const timeDim = query.timeDimensions[0];
|
||||||
|
state.timeDimension = {
|
||||||
|
dimension: timeDim.dimension,
|
||||||
|
granularity: (timeDim.granularity || "day") as TimeDimensionConfig["granularity"],
|
||||||
|
dateRange: (timeDim.dateRange || "last 30 days") as TimeDimensionConfig["dateRange"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert date preset string to date range.
|
||||||
|
*/
|
||||||
|
export function getDateRangeFromPreset(preset: string): [Date, Date] | null {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case "today": {
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
return [today, tomorrow];
|
||||||
|
}
|
||||||
|
case "yesterday": {
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
return [yesterday, today];
|
||||||
|
}
|
||||||
|
case "last 7 days": {
|
||||||
|
const sevenDaysAgo = new Date(today);
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
return [sevenDaysAgo, today];
|
||||||
|
}
|
||||||
|
case "last 30 days": {
|
||||||
|
const thirtyDaysAgo = new Date(today);
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
return [thirtyDaysAgo, today];
|
||||||
|
}
|
||||||
|
case "this month": {
|
||||||
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
return [firstDay, lastDay];
|
||||||
|
}
|
||||||
|
case "last month": {
|
||||||
|
const firstDayLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const firstDayThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
return [firstDayLastMonth, firstDayThisMonth];
|
||||||
|
}
|
||||||
|
case "this quarter": {
|
||||||
|
const quarter = Math.floor(now.getMonth() / 3);
|
||||||
|
const firstDay = new Date(now.getFullYear(), quarter * 3, 1);
|
||||||
|
const lastDay = new Date(now.getFullYear(), (quarter + 1) * 3, 1);
|
||||||
|
return [firstDay, lastDay];
|
||||||
|
}
|
||||||
|
case "this year": {
|
||||||
|
const firstDay = new Date(now.getFullYear(), 0, 1);
|
||||||
|
const lastDay = new Date(now.getFullYear() + 1, 0, 1);
|
||||||
|
return [firstDay, lastDay];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Schema definitions for FeedbackRecords fields.
|
||||||
|
* Used by the advanced chart builder to provide field metadata and operators.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FieldDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: "string" | "number" | "time";
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeasureDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: "count" | "number";
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEEDBACK_FIELDS = {
|
||||||
|
dimensions: [
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.sentiment",
|
||||||
|
label: "Sentiment",
|
||||||
|
type: "string",
|
||||||
|
description: "Sentiment extracted from feedback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.sourceType",
|
||||||
|
label: "Source Type",
|
||||||
|
type: "string",
|
||||||
|
description: "Source type of the feedback (e.g., nps_campaign, survey)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.sourceName",
|
||||||
|
label: "Source Name",
|
||||||
|
type: "string",
|
||||||
|
description: "Human-readable name of the source",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.fieldType",
|
||||||
|
label: "Field Type",
|
||||||
|
type: "string",
|
||||||
|
description: "Type of feedback field (e.g., nps, text, rating)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.emotion",
|
||||||
|
label: "Emotion",
|
||||||
|
type: "string",
|
||||||
|
description: "Emotion extracted from metadata JSONB field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.userIdentifier",
|
||||||
|
label: "User Identifier",
|
||||||
|
type: "string",
|
||||||
|
description: "Identifier of the user who provided feedback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.responseId",
|
||||||
|
label: "Response ID",
|
||||||
|
type: "string",
|
||||||
|
description: "Unique identifier linking related feedback records",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.npsValue",
|
||||||
|
label: "NPS Value",
|
||||||
|
type: "number",
|
||||||
|
description: "Raw NPS score value (0-10)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.collectedAt",
|
||||||
|
label: "Collected At",
|
||||||
|
type: "time",
|
||||||
|
description: "Timestamp when the feedback was collected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TopicsUnnested.topic",
|
||||||
|
label: "Topic",
|
||||||
|
type: "string",
|
||||||
|
description: "Individual topic from the topics array",
|
||||||
|
},
|
||||||
|
] as FieldDefinition[],
|
||||||
|
measures: [
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.count",
|
||||||
|
label: "Count",
|
||||||
|
type: "count",
|
||||||
|
description: "Total number of feedback responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.promoterCount",
|
||||||
|
label: "Promoter Count",
|
||||||
|
type: "count",
|
||||||
|
description: "Number of promoters (NPS score 9-10)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.detractorCount",
|
||||||
|
label: "Detractor Count",
|
||||||
|
type: "count",
|
||||||
|
description: "Number of detractors (NPS score 0-6)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.passiveCount",
|
||||||
|
label: "Passive Count",
|
||||||
|
type: "count",
|
||||||
|
description: "Number of passives (NPS score 7-8)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.npsScore",
|
||||||
|
label: "NPS Score",
|
||||||
|
type: "number",
|
||||||
|
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.averageScore",
|
||||||
|
label: "Average Score",
|
||||||
|
type: "number",
|
||||||
|
description: "Average NPS score",
|
||||||
|
},
|
||||||
|
] as MeasureDefinition[],
|
||||||
|
customAggregations: ["count", "countDistinct", "sum", "avg", "min", "max"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilterOperator =
|
||||||
|
| "equals"
|
||||||
|
| "notEquals"
|
||||||
|
| "contains"
|
||||||
|
| "notContains"
|
||||||
|
| "set"
|
||||||
|
| "notSet"
|
||||||
|
| "gt"
|
||||||
|
| "gte"
|
||||||
|
| "lt"
|
||||||
|
| "lte";
|
||||||
|
|
||||||
|
export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
|
||||||
|
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
|
||||||
|
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||||
|
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
|
||||||
|
|
||||||
|
export type TimeGranularity = (typeof TIME_GRANULARITIES)[number];
|
||||||
|
|
||||||
|
export const DATE_PRESETS = [
|
||||||
|
{ label: "Today", value: "today" },
|
||||||
|
{ label: "Yesterday", value: "yesterday" },
|
||||||
|
{ label: "Last 7 days", value: "last 7 days" },
|
||||||
|
{ label: "Last 30 days", value: "last 30 days" },
|
||||||
|
{ label: "This month", value: "this month" },
|
||||||
|
{ label: "Last month", value: "last month" },
|
||||||
|
{ label: "This quarter", value: "this quarter" },
|
||||||
|
{ label: "This year", value: "this year" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filter operators for a given field type.
|
||||||
|
*/
|
||||||
|
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
|
||||||
|
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field definition by ID.
|
||||||
|
*/
|
||||||
|
export function getFieldById(id: string): FieldDefinition | MeasureDefinition | undefined {
|
||||||
|
const dimension = FEEDBACK_FIELDS.dimensions.find((d) => d.id === id);
|
||||||
|
if (dimension) return dimension;
|
||||||
|
return FEEDBACK_FIELDS.measures.find((m) => m.id === id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// --- Base enums & primitives ---
|
||||||
|
|
||||||
|
export const ZDashboardStatus = z.enum(["published", "draft"]);
|
||||||
|
export type TDashboardStatus = z.infer<typeof ZDashboardStatus>;
|
||||||
|
|
||||||
|
export const ZChartType = z.enum([
|
||||||
|
"area",
|
||||||
|
"bar",
|
||||||
|
"line",
|
||||||
|
"pie",
|
||||||
|
"big_number",
|
||||||
|
"big_number_total",
|
||||||
|
"table",
|
||||||
|
"funnel",
|
||||||
|
"map",
|
||||||
|
]);
|
||||||
|
export type TChartType = z.infer<typeof ZChartType>;
|
||||||
|
|
||||||
|
export const ZApiChartType = z.enum(["bar", "line", "donut", "kpi", "area", "pie"]);
|
||||||
|
export type TApiChartType = z.infer<typeof ZApiChartType>;
|
||||||
|
|
||||||
|
export const ZWidgetType = z.enum(["chart", "markdown", "header", "divider"]);
|
||||||
|
export type TWidgetType = z.infer<typeof ZWidgetType>;
|
||||||
|
|
||||||
|
// --- Layout ---
|
||||||
|
|
||||||
|
export const ZWidgetLayout = z.object({
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number(),
|
||||||
|
w: z.number(),
|
||||||
|
h: z.number(),
|
||||||
|
});
|
||||||
|
export type TWidgetLayout = z.infer<typeof ZWidgetLayout>;
|
||||||
|
|
||||||
|
// --- Cube.js query schemas (shared between actions & components) ---
|
||||||
|
|
||||||
|
export const ZTimeDimension = z.object({
|
||||||
|
dimension: z.string(),
|
||||||
|
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).optional(),
|
||||||
|
dateRange: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
});
|
||||||
|
export type TTimeDimension = z.infer<typeof ZTimeDimension>;
|
||||||
|
|
||||||
|
export const ZFilterOperator = z.enum([
|
||||||
|
"equals",
|
||||||
|
"notEquals",
|
||||||
|
"contains",
|
||||||
|
"notContains",
|
||||||
|
"set",
|
||||||
|
"notSet",
|
||||||
|
"gt",
|
||||||
|
"gte",
|
||||||
|
"lt",
|
||||||
|
"lte",
|
||||||
|
]);
|
||||||
|
export type TFilterOperator = z.infer<typeof ZFilterOperator>;
|
||||||
|
|
||||||
|
export const ZCubeFilter = z.object({
|
||||||
|
member: z.string(),
|
||||||
|
operator: ZFilterOperator,
|
||||||
|
values: z.array(z.string()).optional().nullable(),
|
||||||
|
});
|
||||||
|
export type TCubeFilter = z.infer<typeof ZCubeFilter>;
|
||||||
|
|
||||||
|
export const ZCubeQuery = z.object({
|
||||||
|
measures: z.array(z.string()),
|
||||||
|
dimensions: z.array(z.string()).optional(),
|
||||||
|
timeDimensions: z.array(ZTimeDimension).optional(),
|
||||||
|
filters: z.array(ZCubeFilter).optional(),
|
||||||
|
});
|
||||||
|
export type TCubeQuery = z.infer<typeof ZCubeQuery>;
|
||||||
|
|
||||||
|
// --- Chart config ---
|
||||||
|
|
||||||
|
export const ZChartConfig = z.record(z.string(), z.unknown());
|
||||||
|
export type TChartConfig = z.infer<typeof ZChartConfig>;
|
||||||
|
|
||||||
|
// --- Dashboard widget ---
|
||||||
|
|
||||||
|
export const ZDashboardWidgetChart = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
type: ZChartType,
|
||||||
|
query: ZCubeQuery,
|
||||||
|
config: ZChartConfig,
|
||||||
|
});
|
||||||
|
export type TDashboardWidgetChart = z.infer<typeof ZDashboardWidgetChart>;
|
||||||
|
|
||||||
|
export const ZDashboardWidget = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: ZWidgetType,
|
||||||
|
title: z.string().optional(),
|
||||||
|
chartId: z.string().optional(),
|
||||||
|
layout: ZWidgetLayout,
|
||||||
|
chart: ZDashboardWidgetChart.optional(),
|
||||||
|
});
|
||||||
|
export type TDashboardWidget = z.infer<typeof ZDashboardWidget>;
|
||||||
|
|
||||||
|
// --- Dashboard ---
|
||||||
|
|
||||||
|
export const ZDashboard = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: ZDashboardStatus,
|
||||||
|
lastModified: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
createdBy: z.string().optional(),
|
||||||
|
createdByName: z.string().optional(),
|
||||||
|
chartCount: z.number(),
|
||||||
|
widgets: z.array(ZDashboardWidget),
|
||||||
|
});
|
||||||
|
export type TDashboard = z.infer<typeof ZDashboard>;
|
||||||
|
|
||||||
|
// --- Chart ---
|
||||||
|
|
||||||
|
export const ZChart = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
type: ZChartType,
|
||||||
|
lastModified: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
createdBy: z.string().optional(),
|
||||||
|
createdByName: z.string().optional(),
|
||||||
|
dashboardIds: z.array(z.string()),
|
||||||
|
config: ZChartConfig,
|
||||||
|
});
|
||||||
|
export type TChart = z.infer<typeof ZChart>;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./analysis";
|
||||||
@@ -292,6 +292,15 @@ export const withAuditLogging = <TParsedInput = Record<string, unknown>, TResult
|
|||||||
case "quota":
|
case "quota":
|
||||||
targetId = auditLoggingCtx.quotaId;
|
targetId = auditLoggingCtx.quotaId;
|
||||||
break;
|
break;
|
||||||
|
case "chart":
|
||||||
|
targetId = auditLoggingCtx.chartId;
|
||||||
|
break;
|
||||||
|
case "dashboard":
|
||||||
|
targetId = auditLoggingCtx.dashboardId;
|
||||||
|
break;
|
||||||
|
case "dashboardWidget":
|
||||||
|
targetId = auditLoggingCtx.dashboardWidgetId;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
targetId = UNKNOWN_DATA;
|
targetId = UNKNOWN_DATA;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export const ZAuditTarget = z.enum([
|
|||||||
"integration",
|
"integration",
|
||||||
"file",
|
"file",
|
||||||
"quota",
|
"quota",
|
||||||
|
"chart",
|
||||||
|
"dashboard",
|
||||||
|
"dashboardWidget",
|
||||||
]);
|
]);
|
||||||
export const ZAuditAction = z.enum([
|
export const ZAuditAction = z.enum([
|
||||||
"created",
|
"created",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const generateAttributeTableColumns = (
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const description = row.original.description;
|
const description = row.original.description;
|
||||||
return description ? (
|
return description ? (
|
||||||
<div className={isExpanded ? "break-words whitespace-normal" : "truncate"}>
|
<div className={isExpanded ? "whitespace-normal break-words" : "truncate"}>
|
||||||
<HighlightedText value={description} searchValue={searchValue} />
|
<HighlightedText value={description} searchValue={searchValue} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export const UploadContactsAttributes = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="overflow-hidden font-medium text-ellipsis text-slate-700">{csvColumn}</span>
|
<span className="overflow-hidden text-ellipsis font-medium text-slate-700">{csvColumn}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UploadContactsAttributeCombobox
|
<UploadContactsAttributeCombobox
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function TargetingCard({
|
|||||||
asChild
|
asChild
|
||||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
||||||
<div className="inline-flex px-4 py-4">
|
<div className="inline-flex px-4 py-4">
|
||||||
<div className="flex items-center pr-5 pl-2">
|
<div className="flex items-center pl-2 pr-5">
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ export function EditLanguage({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-slate-500 italic">
|
<p className="text-sm italic text-slate-500">
|
||||||
{t("environments.workspace.languages.no_language_found")}
|
{t("environments.workspace.languages.no_language_found")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export const ActionActivityTab = ({
|
|||||||
<Label className="block text-xs font-normal text-slate-500">Type</Label>
|
<Label className="block text-xs font-normal text-slate-500">Type</Label>
|
||||||
<div className="mt-1 flex items-center">
|
<div className="mt-1 flex items-center">
|
||||||
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
|
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
|
||||||
<p className="text-sm text-slate-700 capitalize">{actionClass.type}</p>
|
<p className="text-sm capitalize text-slate-700">{actionClass.type}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="">
|
<div className="">
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export const EndScreenForm = ({
|
|||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
{/* The highlight container is absolutely positioned behind the input */}
|
{/* The highlight container is absolutely positioned behind the input */}
|
||||||
<div
|
<div
|
||||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
|
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
key={highlightedJSX.toString()}>
|
key={highlightedJSX.toString()}>
|
||||||
{highlightedJSX}
|
{highlightedJSX}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const HiddenFieldsCard = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||||
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
|
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||||
)}>
|
)}>
|
||||||
<EyeOff className="h-4 w-4" />
|
<EyeOff className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +191,7 @@ export const HiddenFieldsCard = ({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-sm text-slate-500 italic">
|
<p className="mt-2 text-sm italic text-slate-500">
|
||||||
{t("environments.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
|
{t("environments.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ export const SurveyMenuBar = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
|
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
|
||||||
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
|
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
|
||||||
{!isStorageConfigured && (
|
{!isStorageConfigured && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
import { ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_VARIABLE }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContainer = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<typeof ResponsiveContainer>["children"];
|
||||||
|
}
|
||||||
|
>(({ id, className, children, config, ...props }, ref) => {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-chart={chartId}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<ResponsiveContainer>{children}</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ChartContainer.displayName = "ChartContainer";
|
||||||
|
|
||||||
|
const ChartTooltip = Tooltip;
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<typeof Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: any[];
|
||||||
|
label?: string;
|
||||||
|
labelFormatter?: (value: any, payload: any[]) => React.ReactNode;
|
||||||
|
labelClassName?: string;
|
||||||
|
formatter?: (value: any, name: string, item: any, index: number, payload: any[]) => React.ReactNode;
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
})}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ChartTooltipContent.displayName = "ChartTooltip";
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
const ChartLegendContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
payload?: any[];
|
||||||
|
verticalAlign?: "top" | "bottom" | "middle";
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}
|
||||||
|
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ChartLegendContent.displayName = "ChartLegend";
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
||||||
@@ -68,7 +68,7 @@ export const DataTableHeader = <T,>({
|
|||||||
onTouchStart={header.getResizeHandler()}
|
onTouchStart={header.getResizeHandler()}
|
||||||
data-testid="column-resize-handle"
|
data-testid="column-resize-handle"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-0 right-0 hidden h-full w-1 cursor-col-resize bg-slate-500",
|
"absolute right-0 top-0 hidden h-full w-1 cursor-col-resize bg-slate-500",
|
||||||
header.column.getIsResizing() ? "bg-black" : "bg-slate-500",
|
header.column.getIsResizing() ? "bg-black" : "bg-slate-500",
|
||||||
!header.column.getCanResize() ? "hidden" : "group-hover:block"
|
!header.column.getCanResize() ? "hidden" : "group-hover:block"
|
||||||
)}></button>
|
)}></button>
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-controls="options"
|
aria-controls="options"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn("flex w-full cursor-pointer items-center justify-end bg-white pr-2 h-10", {
|
className={cn("flex h-10 w-full cursor-pointer items-center justify-end bg-white pr-2", {
|
||||||
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
|
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
|
||||||
"pointer-events-none": isClearing,
|
"pointer-events-none": isClearing,
|
||||||
})}>
|
})}>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
|||||||
const [inputValue, setInputValue] = React.useState("");
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
const [position, setPosition] = React.useState<{ top: number; left: number; width: number } | null>(null);
|
const [position, setPosition] = React.useState<{ top: number; left: number; width: number } | null>(null);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const isSelectingRef = React.useRef(false);
|
||||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
// Track if changes are user-initiated (not from value prop)
|
// Track if changes are user-initiated (not from value prop)
|
||||||
@@ -193,7 +194,12 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onValueChange={setInputValue}
|
onValueChange={setInputValue}
|
||||||
onBlur={() => setOpen(false)}
|
onBlur={(_e) => {
|
||||||
|
// Don't close if we're selecting an option
|
||||||
|
if (!isSelectingRef.current) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -224,12 +230,18 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
isSelectingRef.current = true;
|
||||||
}}
|
}}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
isUserInitiatedRef.current = true; // Mark as user-initiated
|
isUserInitiatedRef.current = true; // Mark as user-initiated
|
||||||
setSelected((prev) => [...prev, option]);
|
setSelected((prev) => [...prev, option]);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
|
// Reset the flag after a short delay to allow the selection to complete
|
||||||
|
setTimeout(() => {
|
||||||
|
isSelectingRef.current = false;
|
||||||
|
setOpen(false);
|
||||||
|
}, 100);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer">
|
className="cursor-pointer">
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 focus:outline-none hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}>
|
{...props}>
|
||||||
@@ -62,7 +62,7 @@ const SelectLabel: React.ComponentType<SelectPrimitive.SelectLabelProps> = React
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -75,7 +75,7 @@ const SelectItem: React.ComponentType<SelectPrimitive.SelectItemProps> = React.f
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-pointer items-center rounded-md py-1.5 pr-2 pl-2 text-sm font-medium outline-none select-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}>
|
{...props}>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const StylingTabs = <T extends string | number>({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 cursor-pointer items-center justify-center gap-4 rounded-md py-2 text-center text-sm",
|
"flex flex-1 cursor-pointer items-center justify-center gap-4 rounded-md py-2 text-center text-sm",
|
||||||
selectedOption === option.value ? "bg-slate-100" : "bg-white",
|
selectedOption === option.value ? "bg-slate-100" : "bg-white",
|
||||||
"focus:ring-brand-dark focus:ring-opacity-50 focus:ring-2 focus:outline-none",
|
"focus:ring-brand-dark focus:outline-none focus:ring-2 focus:ring-opacity-50",
|
||||||
selectedOption === option.value ? activeTabClassName : inactiveTabClassName
|
selectedOption === option.value ? activeTabClassName : inactiveTabClassName
|
||||||
)}>
|
)}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user