implement ui feedback

This commit is contained in:
Dhruwang
2026-02-02 10:15:22 +05:30
parent ef56e97c95
commit 36955ddbb8
25 changed files with 1700 additions and 750 deletions

View File

@@ -3,8 +3,9 @@
import * as Collapsible from "@radix-ui/react-collapsible";
import { CodeIcon, DatabaseIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useReducer, useState } from "react";
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 {
@@ -14,6 +15,11 @@ import {
getDashboardsAction,
} from "../../actions";
import { CHART_TYPES } from "../lib/chart-types";
// Filter out table, map, and scatter charts
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter(
(type) => !["table", "map", "scatter"].includes(type.id)
);
import { mapChartType } from "../lib/chart-utils";
import {
ChartBuilderState,
@@ -21,6 +27,7 @@ import {
FilterRow,
TimeDimensionConfig,
buildCubeQuery,
parseQueryToState,
} from "../lib/query-builder";
import { AddToDashboardDialog } from "./AddToDashboardDialog";
import { ChartRenderer } from "./ChartRenderer";
@@ -33,6 +40,11 @@ import { TimeDimensionPanel } from "./TimeDimensionPanel";
interface AdvancedChartBuilderProps {
environmentId: string;
initialChartType?: string;
initialQuery?: any; // Prefill with AI-generated query
hidePreview?: boolean; // Hide internal preview when using unified preview
onChartGenerated?: (data: AnalyticsResponse) => void;
onSave?: (chartId: string) => void;
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
}
type Action =
@@ -83,14 +95,154 @@ function chartBuilderReducer(state: ChartBuilderState, action: Action): ChartBui
}
}
export function AdvancedChartBuilder({ environmentId, initialChartType }: AdvancedChartBuilderProps) {
export function AdvancedChartBuilder({
environmentId,
initialChartType,
initialQuery,
hidePreview = false,
onChartGenerated,
onSave,
onAddToDashboard,
}: AdvancedChartBuilderProps) {
const router = useRouter();
const [state, dispatch] = useReducer(chartBuilderReducer, {
...initialState,
chartType: initialChartType || "",
});
// Initialize state from initialQuery if provided
const getInitialState = (): ChartBuilderState => {
if (initialQuery) {
const parsedState = parseQueryToState(initialQuery, initialChartType);
return {
...initialState,
...parsedState,
chartType: parsedState.chartType || initialChartType || "",
};
}
return {
...initialState,
chartType: initialChartType || "",
};
};
const [state, dispatch] = useReducer(chartBuilderReducer, getInitialState());
const [chartData, setChartData] = useState<Record<string, any>[] | null>(null);
const [query, setQuery] = useState<any>(null);
const [query, setQuery] = useState<any>(initialQuery || null);
const [isInitialized, setIsInitialized] = useState(false);
const lastStateRef = React.useRef<string>("");
// Sync initialChartType prop changes to state
useEffect(() => {
if (initialChartType && initialChartType !== state.chartType) {
dispatch({ type: "SET_CHART_TYPE", payload: initialChartType });
// If there's no initialQuery, mark as initialized so reactive updates can work
if (!initialQuery && !isInitialized) {
setIsInitialized(true);
}
}
}, [initialChartType, state.chartType, initialQuery, isInitialized]);
// Initialize: If initialQuery is provided (from AI), execute it and set chart data
useEffect(() => {
if (initialQuery && !isInitialized) {
setIsInitialized(true);
executeQueryAction({
environmentId,
query: initialQuery,
}).then((result) => {
if (result?.data?.data) {
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setQuery(initialQuery);
// Set initial state hash to prevent reactive update on initial load
lastStateRef.current = JSON.stringify({
chartType: state.chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
// Call onChartGenerated if provided
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: initialQuery,
chartType: state.chartType as any,
data,
};
onChartGenerated(analyticsResponse);
}
}
});
}
}, [initialQuery, environmentId, isInitialized, state.chartType, state.selectedMeasures, state.selectedDimensions, state.filters, state.timeDimension, onChartGenerated]);
// Update preview reactively when state changes (after initialization)
useEffect(() => {
// Skip if not initialized or no chart type selected
if (!isInitialized || !state.chartType) return;
// Create a hash of relevant state to detect changes
const stateHash = JSON.stringify({
chartType: state.chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
// Only update if state actually changed
if (stateHash === lastStateRef.current) return;
lastStateRef.current = stateHash;
// If chart type changed but we have existing data, update the chart type in preview immediately
// This handles the case where user changes chart type from ManualChartBuilder
if (chartData && Array.isArray(chartData) && chartData.length > 0 && query) {
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: query, // Keep existing query
chartType: state.chartType as any, // Update chart type
data: chartData, // Keep existing data
};
onChartGenerated(analyticsResponse);
}
}
// Only execute query if we have measures configured
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
return; // Don't execute query without measures
}
// Build and execute query with current state
const updatedQuery = buildCubeQuery(state);
setIsLoading(true);
setError(null);
executeQueryAction({
environmentId,
query: updatedQuery,
})
.then((result) => {
if (result?.data?.data) {
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setQuery(updatedQuery);
// Call onChartGenerated to update parent preview
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: updatedQuery,
chartType: state.chartType as any,
data,
};
onChartGenerated(analyticsResponse);
}
} else if (result?.serverError) {
setError(result.serverError);
}
})
.catch((err: any) => {
setError(err.message || "Failed to execute query");
})
.finally(() => {
setIsLoading(false);
});
}, [state.chartType, state.selectedMeasures, state.selectedDimensions, state.filters, state.timeDimension, isInitialized, environmentId, onChartGenerated, chartData, query]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
@@ -146,6 +298,16 @@ export function AdvancedChartBuilder({ environmentId, initialChartType }: Advanc
setChartData(data);
setError(null);
toast.success("Query executed successfully");
// Call onChartGenerated callback if provided
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: cubeQuery,
chartType: state.chartType as any,
data,
};
onChartGenerated(analyticsResponse);
}
} else {
throw new Error("No data returned");
}
@@ -186,7 +348,11 @@ export function AdvancedChartBuilder({ environmentId, initialChartType }: Advanc
toast.success("Chart saved successfully!");
setIsSaveDialogOpen(false);
router.push(`/environments/${environmentId}/analysis/charts`);
if (onSave) {
onSave(result.data.id);
} else {
router.push(`/environments/${environmentId}/analysis/charts`);
}
} catch (error: any) {
toast.error(error.message || "Failed to save chart");
} finally {
@@ -233,7 +399,11 @@ export function AdvancedChartBuilder({ environmentId, initialChartType }: Advanc
toast.success("Chart added to dashboard!");
setIsAddToDashboardDialogOpen(false);
router.push(`/environments/${environmentId}/analysis/dashboard/${selectedDashboardId}`);
if (onAddToDashboard) {
onAddToDashboard(chartResult.data.id, selectedDashboardId);
} else {
router.push(`/environments/${environmentId}/analysis/dashboard/${selectedDashboardId}`);
}
} catch (error: any) {
toast.error(error.message || "Failed to add chart to dashboard");
} finally {
@@ -242,39 +412,35 @@ export function AdvancedChartBuilder({ environmentId, initialChartType }: Advanc
};
return (
<div className="grid gap-8 lg:grid-cols-2">
<div className={hidePreview ? "space-y-4" : "grid gap-4 lg:grid-cols-2"}>
{/* Left Column: Configuration */}
<div className="space-y-8">
{/* Chart Type Selection */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
1
</span>
<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>
<div className="ml-8 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{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
<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 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>
)}
{/* Measures Panel */}
<MeasuresPanel
@@ -309,7 +475,7 @@ export function AdvancedChartBuilder({ environmentId, initialChartType }: Advanc
<Button onClick={handleRunQuery} disabled={isLoading || !state.chartType}>
{isLoading ? <LoadingSpinner /> : "Run Query"}
</Button>
{chartData && (
{chartData && !onSave && !onAddToDashboard && (
<>
<Button variant="outline" onClick={() => setIsSaveDialogOpen(true)}>
Save Chart
@@ -322,124 +488,130 @@ export function AdvancedChartBuilder({ environmentId, initialChartType }: Advanc
</div>
</div>
{/* Right Column: Preview */}
<div className="space-y-4">
<h3 className="font-medium text-gray-900">Chart Preview</h3>
{/* 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>
)}
{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} />
{isLoading && (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</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>
{chartData && Array.isArray(chartData) && chartData.length > 0 && !isLoading && (
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<ChartRenderer chartType={state.chartType} data={chartData} />
</div>
{/* 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>
{/* 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.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.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>
{!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 */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
{/* Dialogs - Only render when callbacks are not provided (standalone mode) */}
{!onSave && (
<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}
/>
{!onAddToDashboard && (
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
)}
</div>
);
}

View File

@@ -38,7 +38,6 @@ export function ChartBuilderClient({ environmentId, chartId }: ChartBuilderClien
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);
const [configuredChartType, setConfiguredChartType] = useState<string | null>(null);
const [showAdvancedBuilder, setShowAdvancedBuilder] = useState(false);
@@ -244,14 +243,9 @@ export function ChartBuilderClient({ environmentId, chartId }: ChartBuilderClien
<ChartPreview
chartData={chartData}
configuredChartType={configuredChartType}
showQuery={showQuery}
showData={showData}
isSaving={isSaving}
onToggleQuery={() => setShowQuery(!showQuery)}
onToggleData={() => setShowData(!showData)}
onConfigure={() => setIsConfigureDialogOpen(true)}
onSave={() => setIsSaveDialogOpen(true)}
onAddToDashboard={() => setIsAddToDashboardDialogOpen(true)}
/>
{/* Dialogs */}
@@ -298,14 +292,9 @@ export function ChartBuilderClient({ environmentId, chartId }: ChartBuilderClien
<ChartPreview
chartData={chartData}
configuredChartType={configuredChartType}
showQuery={showQuery}
showData={showData}
isSaving={isSaving}
onToggleQuery={() => setShowQuery(!showQuery)}
onToggleData={() => setShowData(!showData)}
onConfigure={() => setIsConfigureDialogOpen(true)}
onSave={() => setIsSaveDialogOpen(true)}
onAddToDashboard={() => setIsAddToDashboardDialogOpen(true)}
/>
)}

View File

@@ -1,99 +1,46 @@
"use client";
import { CodeIcon, DatabaseIcon, EyeOffIcon, PlusIcon, SaveIcon, SettingsIcon } from "lucide-react";
import { useState } from "react";
import { BarChart, DatabaseIcon } from "lucide-react";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { Button } from "@/modules/ui/components/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { ChartRenderer } from "./ChartRenderer";
import { DataViewer } from "./DataViewer";
import { QueryViewer } from "./QueryViewer";
interface ChartPreviewProps {
chartData: AnalyticsResponse;
configuredChartType: string | null;
showQuery: boolean;
showData: boolean;
isSaving: boolean;
onToggleQuery: () => void;
onToggleData: () => void;
onConfigure: () => void;
onSave: () => void;
onAddToDashboard: () => void;
}
export function ChartPreview({
chartData,
configuredChartType,
showQuery,
showData,
isSaving,
onToggleQuery,
onToggleData,
onConfigure,
onSave,
onAddToDashboard,
}: ChartPreviewProps) {
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 className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={onToggleQuery}
className="text-gray-600 hover:text-gray-900">
{showQuery ? (
<>
<EyeOffIcon className="mr-2 h-4 w-4" />
Hide Query
</>
) : (
<>
<CodeIcon className="mr-2 h-4 w-4" />
View Query
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onToggleData}
className="text-gray-600 hover:text-gray-900">
{showData ? (
<>
<EyeOffIcon className="mr-2 h-4 w-4" />
Hide Data
</>
) : (
<>
<DatabaseIcon className="mr-2 h-4 w-4" />
View Data
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onConfigure}
className="text-gray-600 hover:text-gray-900">
<SettingsIcon className="mr-2 h-4 w-4" />
Configure
</Button>
<Button variant="outline" size="sm" onClick={onAddToDashboard} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
Add to Dashboard
</Button>
<Button variant="default" size="sm" onClick={onSave} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
Save Chart
</Button>
</div>
</div>
<ChartRenderer chartType={configuredChartType || chartData.chartType} data={chartData.data || []} />
<QueryViewer query={chartData.query} isOpen={showQuery} onOpenChange={onToggleQuery} />
<DataViewer data={chartData.data || []} isOpen={showData} onOpenChange={onToggleData} />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "chart" | "data")}>
<div className="flex justify-end mb-4">
<TabsList>
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
Chart
</TabsTrigger>
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
Data
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chart" className="mt-0">
<ChartRenderer chartType={chartData.chartType} data={chartData.data || []} />
</TabsContent>
<TabsContent value="data" className="mt-0">
<DataViewer data={chartData.data || []} />
</TabsContent>
</Tabs>
</div>
</div>
);

View File

@@ -1,6 +1,5 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { DatabaseIcon } from "lucide-react";
interface DataViewerProps {
@@ -9,56 +8,56 @@ interface DataViewerProps {
onOpenChange: (open: boolean) => void;
}
export function DataViewer({ data, isOpen, onOpenChange }: DataViewerProps) {
export function DataViewer({ data }: Omit<DataViewerProps, "isOpen" | "onOpenChange">) {
if (!data || data.length === 0) {
return null;
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 (
<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">
<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>
<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>
</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>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
})}
</tbody>
</table>
{data.length > 50 && (
<div className="px-3 py-2 text-xs text-gray-500">Showing first 50 of {data.length} rows</div>
)}
</div>
</div>
);
}

View File

@@ -16,14 +16,9 @@ export function DimensionsPanel({ selectedDimensions, onDimensionsChange }: Dime
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
3
</span>
<h3 className="font-medium text-gray-900">Dimensions</h3>
</div>
<h3 className="font-medium text-gray-900">Dimensions</h3>
<div className="ml-8 space-y-2">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Group By</label>
<MultiSelect
options={dimensionOptions}

View File

@@ -150,12 +150,7 @@ export function FiltersPanel({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
5
</span>
<h3 className="font-medium text-gray-900">Filters</h3>
</div>
<h3 className="font-medium text-gray-900">Filters</h3>
<Select value={filterLogic} onValueChange={(value) => onFilterLogicChange(value as "and" | "or")}>
<SelectTrigger className="w-[100px]">
<SelectValue />
@@ -167,7 +162,7 @@ export function FiltersPanel({
</Select>
</div>
<div className="ml-8 space-y-3">
<div className="space-y-3">
{filters.map((filter, index) => {
const field = getFieldById(filter.field);
const fieldType = field?.type || "string";

View File

@@ -1,79 +1,49 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { CHART_TYPES } from "../lib/chart-types";
interface ManualChartBuilderProps {
selectedChartType: string;
onChartTypeSelect: (type: string) => void;
onCreate: () => void;
}
// Filter out table, map, and scatter charts
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter(
(type) => !["table", "map", "scatter"].includes(type.id)
);
export function ManualChartBuilder({
selectedChartType,
onChartTypeSelect,
onCreate,
}: ManualChartBuilderProps) {
const [searchQuery, setSearchQuery] = useState("");
const filteredChartTypes = CHART_TYPES.filter((type) =>
type.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}: Omit<ManualChartBuilderProps, "onCreate">) {
return (
<div className="space-y-8">
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
1
</span>
<h2 className="font-medium text-gray-900">Choose chart type</h2>
<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 className="ml-8 rounded-lg border border-gray-200 bg-white p-4">
<div className="relative mb-4">
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="Search all charts"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{filteredChartTypes.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>
<div className="flex justify-end pt-2">
<Button disabled={!selectedChartType} variant="outline" onClick={onCreate}>
Create Manually
</Button>
</div>
</div>
);

View File

@@ -66,14 +66,9 @@ export function MeasuresPanel({
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
2
</span>
<h3 className="font-medium text-gray-900">Measures</h3>
</div>
<h3 className="font-medium text-gray-900">Measures</h3>
<div className="ml-8 space-y-4">
<div className="space-y-4">
{/* Predefined Measures */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Predefined Measures</label>

View File

@@ -89,13 +89,8 @@ export function TimeDimensionPanel({
if (!timeDimension) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
4
</span>
<h3 className="font-medium text-gray-900">Time Dimension</h3>
</div>
<div className="ml-8">
<h3 className="font-medium text-gray-900">Time Dimension</h3>
<div>
<Button type="button" variant="outline" onClick={handleEnableTimeDimension}>
Enable Time Dimension
</Button>
@@ -107,18 +102,13 @@ export function TimeDimensionPanel({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
4
</span>
<h3 className="font-medium text-gray-900">Time Dimension</h3>
</div>
<h3 className="font-medium text-gray-900">Time Dimension</h3>
<Button type="button" variant="ghost" size="sm" onClick={handleDisableTimeDimension}>
Disable
</Button>
</div>
<div className="ml-8 space-y-3">
<div className="space-y-3">
{/* Field Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Field</label>

View File

@@ -94,6 +94,42 @@ export function buildCubeQuery(config: ChartBuilderState): CubeQuery {
return query;
}
/**
* Parse a Cube.js query back into ChartBuilderState
*/
export function parseQueryToState(query: CubeQuery, chartType?: string): Partial<ChartBuilderState> {
const state: Partial<ChartBuilderState> = {
chartType: chartType || "",
selectedMeasures: query.measures || [],
customMeasures: [],
selectedDimensions: query.dimensions || [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
// Parse filters
if (query.filters && query.filters.length > 0) {
state.filters = query.filters.map((f) => ({
field: f.member,
operator: f.operator,
values: f.values || null,
}));
}
// Parse time dimensions
if (query.timeDimensions && query.timeDimensions.length > 0) {
const timeDim = query.timeDimensions[0];
state.timeDimension = {
dimension: timeDim.dimension,
granularity: (timeDim.granularity || "day") as TimeDimensionConfig["granularity"],
dateRange: timeDim.dateRange || "last 30 days",
};
}
return state;
}
/**
* Convert date preset string to date range
*/

View File

@@ -1,22 +0,0 @@
import { ChartBuilderClient } from "./components/ChartBuilderClient";
interface ChartBuilderPageProps {
params: Promise<{ environmentId: string }>;
searchParams: Promise<{ chartId?: string }>;
}
export default async function ChartBuilderPage({ params, searchParams }: ChartBuilderPageProps) {
const { environmentId } = await params;
const { chartId } = await searchParams;
return (
<>
<div className="mb-8">
<h1 className="text-2xl font-semibold text-gray-900">
{chartId ? "Edit chart" : "Create a new chart"}
</h1>
</div>
<ChartBuilderClient environmentId={environmentId} chartId={chartId} />
</>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { TChart } from "../../types/analysis";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
interface ChartDropdownMenuProps {
environmentId: string;
chart: TChart;
disabled?: boolean;
deleteChart: (chartId: string) => void;
onEdit?: () => void;
}
export const ChartDropdownMenu = ({
environmentId,
chart,
disabled,
deleteChart,
onEdit,
}: ChartDropdownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDeleteChart = async (chartId: string) => {
setLoading(true);
try {
// TODO: Implement deleteChartAction
// await deleteChartAction({ chartId });
deleteChart(chartId);
toast.success("Chart deleted successfully");
} catch (error) {
toast.error("Error deleting chart");
} finally {
setLoading(false);
}
};
return (
<div
id={`${chart.name.toLowerCase().split(" ").join("-")}-chart-actions`}
data-testid="chart-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}
onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open options</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onEdit?.();
}}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
// TODO: Implement duplicate functionality
toast.success("Duplicate functionality coming soon");
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Chart"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteChart(chart.id)}
text="Are you sure you want to delete this chart? This action cannot be undone."
isDeleting={loading}
/>
</div>
);
};

View File

@@ -1,98 +1,133 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Edit2Icon, PlusIcon, SearchIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { TChart, TDashboard } from "../../types/analysis";
import { format, formatDistanceToNow } from "date-fns";
import { useState } from "react";
import {
ActivityIcon,
AreaChartIcon,
BarChart3Icon,
LineChartIcon,
MapIcon,
PieChartIcon,
ScatterChart,
TableIcon,
} from "lucide-react";
import { TChart } from "../../types/analysis";
import { ChartDropdownMenu } from "./ChartDropdownMenu";
import { CreateChartDialog } from "./CreateChartDialog";
interface ChartsListClientProps {
charts: TChart[];
dashboards: TDashboard[];
dashboards: any[];
environmentId: string;
}
export function ChartsListClient({ charts, dashboards, environmentId }: ChartsListClientProps) {
// Helper to find dashboard names
const getDashboardNames = (dashboardIds: string[]) => {
return dashboardIds
.map((id) => dashboards.find((d) => d.id === id)?.name)
.filter(Boolean)
.join(", ");
const CHART_TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
area: AreaChartIcon,
bar: BarChart3Icon,
line: LineChartIcon,
pie: PieChartIcon,
table: TableIcon,
big_number: ActivityIcon,
big_number_total: ActivityIcon,
scatter: ScatterChart,
map: MapIcon,
};
export function ChartsListClient({ charts: initialCharts, dashboards, environmentId }: ChartsListClientProps) {
const [charts, setCharts] = useState(initialCharts);
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const filteredCharts = charts;
const deleteChart = (chartId: string) => {
setCharts(charts.filter((c) => c.id !== chartId));
};
const getChartIcon = (type: string) => {
const IconComponent = CHART_TYPE_ICONS[type] || BarChart3Icon;
return <IconComponent className="h-5 w-5" />;
};
const handleChartClick = (chartId: string) => {
setEditingChartId(chartId);
setIsEditDialogOpen(true);
};
const handleEditSuccess = () => {
// Refresh charts list if needed
setIsEditDialogOpen(false);
setEditingChartId(undefined);
};
return (
<div className="flex h-full flex-col">
{/* Header / Actions */}
<div className="flex flex-col gap-4 border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-900"></h1>
<div className="flex items-center gap-2">
<Link href="chart-builder">
<Button size="sm">
<PlusIcon className="mr-2 h-4 w-4" />
Chart
</Button>
</Link>
</div>
</div>
<div className="flex items-center gap-4"></div>
</div>
{/* Table Content */}
<div className="flex-1 overflow-auto bg-gray-50 pt-6">
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
<table className="w-full text-left text-sm text-gray-600">
<thead className="bg-gray-50 text-xs font-semibold uppercase text-gray-500">
<tr>
<th className="border-b border-gray-200 px-6 py-3">Name</th>
<th className="border-b border-gray-200 px-6 py-3">Type</th>
<th className="border-b border-gray-200 px-6 py-3">Dataset</th>
<th className="border-b border-gray-200 px-6 py-3">On dashboards</th>
{/* Hiding owners to save space if needed, mirroring Superset compact view */}
<th className="border-b border-gray-200 px-6 py-3">Last Modified</th>
<th className="border-b border-gray-200 px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{charts.map((chart) => (
<tr key={chart.id} className="group transition-colors hover:bg-gray-50">
<td className="text-brand-dark px-6 py-4 font-medium">
<Link href={`/environments/${environmentId}/analysis/chart-builder?chartId=${chart.id}`}>
<span className="cursor-pointer hover:underline">{chart.name}</span>
</Link>
</td>
<td className="px-6 py-4 text-gray-900">
{chart.type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())}
</td>
<td className="text-brand-dark cursor-pointer px-6 py-4 hover:underline">
{chart.dataset}
</td>
<td className="text-brand-dark px-6 py-4">
{getDashboardNames(chart.dashboardIds) || "-"}
</td>
<td className="px-6 py-4">
{formatDistanceToNow(new Date(chart.lastModified), { addSuffix: true })}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100">
<TrashIcon className="h-4 w-4 text-gray-500" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100">
<Edit2Icon className="h-4 w-4 text-gray-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{charts.length === 0 && <div className="p-12 text-center text-gray-500">No charts found.</div>}
</div>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">Title</div>
<div className="col-span-1 hidden text-center sm:block">Created By</div>
<div className="col-span-1 hidden text-center sm:block">Created</div>
<div className="col-span-1 hidden text-center sm:block">Updated</div>
<div className="col-span-1"></div>
</div>
{filteredCharts.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">No charts found.</p>
) : (
<>
{filteredCharts.map((chart) => (
<div
key={chart.id}
onClick={() => handleChartClick(chart.id)}
className="grid h-12 w-full cursor-pointer grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
{getChartIcon(chart.type)}
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{chart.createdByName || "-"}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{format(new Date(chart.createdAt), "do 'of' MMMM, yyyy")}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(new Date(chart.updatedAt), {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div
className="col-span-1 my-auto flex items-center justify-end pr-6"
onClick={(e) => e.stopPropagation()}>
<ChartDropdownMenu
environmentId={environmentId}
chart={chart}
deleteChart={deleteChart}
onEdit={() => {
setEditingChartId(chart.id);
setIsEditDialogOpen(true);
}}
/>
</div>
</div>
))}
</>
)}
<CreateChartDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
environmentId={environmentId}
chartId={editingChartId}
onSuccess={handleEditSuccess}
/>
</div>
);
}

View File

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

View File

@@ -0,0 +1,431 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
addChartToDashboardAction,
createChartAction,
executeQueryAction,
getChartAction,
getDashboardsAction,
updateChartAction,
} from "../../actions";
import { PlusIcon, SaveIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import { mapChartType, mapDatabaseChartTypeToApi } from "../../chart-builder/lib/chart-utils";
import { AIQuerySection } from "../../chart-builder/components/AIQuerySection";
import { AddToDashboardDialog } from "../../chart-builder/components/AddToDashboardDialog";
import { AdvancedChartBuilder } from "../../chart-builder/components/AdvancedChartBuilder";
import { ChartPreview } from "../../chart-builder/components/ChartPreview";
import { ManualChartBuilder } from "../../chart-builder/components/ManualChartBuilder";
import { SaveChartDialog } from "../../chart-builder/components/SaveChartDialog";
interface CreateChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
onSuccess?: () => void;
}
export function CreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
onSuccess,
}: CreateChartDialogProps) {
const [selectedChartType, setSelectedChartType] = useState<string>("");
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
// Determine if we should show AdvancedChartBuilder
const shouldShowAdvancedBuilder = !!selectedChartType || !!chartData;
useEffect(() => {
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (result?.data) {
setDashboards(result.data);
} else if (result?.serverError) {
toast.error(result.serverError);
}
});
}
}, [isAddToDashboardDialogOpen, environmentId]);
useEffect(() => {
if (open && chartId) {
setIsLoadingChart(true);
getChartAction({ environmentId, chartId })
.then(async (result) => {
if (result?.data) {
const chart = result.data;
setChartName(chart.name);
// Execute the chart's query to get the data
const queryResult = await executeQueryAction({
environmentId,
query: chart.query as any,
});
if (queryResult?.error || queryResult?.serverError) {
toast.error(queryResult.error || queryResult.serverError || "Failed to load chart data");
setIsLoadingChart(false);
return;
}
if (queryResult?.data?.data) {
// Format as AnalyticsResponse
const chartData: AnalyticsResponse = {
query: chart.query as any,
chartType: mapDatabaseChartTypeToApi(chart.type),
data: Array.isArray(queryResult.data.data) ? queryResult.data.data : [],
};
setChartData(chartData);
setCurrentChartId(chart.id);
} else {
toast.error("No data returned for chart");
}
} else if (result?.serverError) {
toast.error(result.serverError);
}
setIsLoadingChart(false);
})
.catch((error: any) => {
toast.error(error.message || "Failed to load chart");
setIsLoadingChart(false);
});
} else if (open && !chartId) {
// Reset state for new chart
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
}
}, [open, chartId, environmentId]);
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
// Set chart type so AdvancedChartBuilder shows with the AI-generated chart type
if (data.chartType) {
setSelectedChartType(data.chartType);
}
};
const handleSaveChart = async () => {
if (!chartData || !chartName.trim()) {
toast.error("Please enter a chart name");
return;
}
setIsSaving(true);
try {
// If we have a currentChartId, update the existing chart; otherwise create a new one
if (currentChartId) {
const result = await updateChartAction({
environmentId,
chartId: currentChartId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to update chart");
return;
}
toast.success("Chart updated successfully!");
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
} else {
const result = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to save chart");
return;
}
setCurrentChartId(result.data.id);
toast.success("Chart saved successfully!");
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
}
} catch (error: any) {
toast.error(error.message || "Failed to save chart");
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error("Please select a dashboard");
return;
}
setIsSaving(true);
try {
let chartIdToUse = currentChartId;
// If we don't have a chartId (creating new chart), create it first
if (!chartIdToUse) {
if (!chartName.trim()) {
toast.error("Please enter a chart name");
setIsSaving(false);
return;
}
const chartResult = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!chartResult?.data) {
toast.error(chartResult?.serverError || "Failed to save chart");
setIsSaving(false);
return;
}
chartIdToUse = chartResult.data.id;
setCurrentChartId(chartIdToUse);
}
// Add the chart (existing or newly created) to the dashboard
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartIdToUse,
dashboardId: selectedDashboardId,
title: chartName.trim(),
layout: { x: 0, y: 0, w: 4, h: 3 },
});
if (!widgetResult?.data) {
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
return;
}
toast.success("Chart added to dashboard!");
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
onSuccess?.();
} catch (error: any) {
toast.error(error.message || "Failed to add chart to dashboard");
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
onOpenChange(false);
}
};
// If loading an existing chart, show loading state
if (chartId && isLoadingChart) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-[90vw] max-h-[90vh] overflow-y-auto">
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
</DialogContent>
</Dialog>
);
}
// If viewing an existing chart, show only the chart preview
if (chartId && chartData) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-7xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Chart</DialogTitle>
<DialogDescription>View and edit your chart configuration.</DialogDescription>
</DialogHeader>
<DialogBody>
<ChartPreview chartData={chartData} />
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
Add to Dashboard
</Button>
<Button onClick={() => setIsSaveDialogOpen(true)} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
Save Chart
</Button>
</DialogFooter>
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
<DialogHeader>
<DialogTitle>{chartId ? "Edit Chart" : "Create Chart"}</DialogTitle>
<DialogDescription>
{chartId
? "View and edit your chart configuration."
: "Use AI to generate a chart or build one manually."}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4">
{/* AI Query Section */}
<AIQuerySection onChartGenerated={handleChartGenerated} />
{/* OR Separator */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-2 text-sm text-gray-500">OR</span>
</div>
</div>
{/* Chart Type Selection */}
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={setSelectedChartType}
/>
{/* Advanced Builder - shown when chart type selected OR AI chart generated */}
{shouldShowAdvancedBuilder && (
<AdvancedChartBuilder
environmentId={environmentId}
initialChartType={selectedChartType || chartData?.chartType || ""}
initialQuery={chartData?.query}
hidePreview={true}
onChartGenerated={(data) => {
setChartData(data);
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
// Update selectedChartType when chart type changes in AdvancedChartBuilder
if (data.chartType) {
setSelectedChartType(data.chartType);
}
}}
onSave={(chartId) => {
setCurrentChartId(chartId);
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
}}
onAddToDashboard={(chartId, dashboardId) => {
setCurrentChartId(chartId);
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
onSuccess?.();
}}
/>
)}
{/* Single Chart Preview - shown when chartData exists */}
{chartData && <ChartPreview chartData={chartData} />}
</div>
</DialogBody>
{chartData && (
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
Add to Dashboard
</Button>
<Button onClick={() => setIsSaveDialogOpen(true)} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
Save Chart
</Button>
</DialogFooter>
)}
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,6 +2,8 @@
import { usePathname } from "next/navigation";
import { use } from "react";
import { CreateChartButton } from "../charts/components/CreateChartButton";
import { CreateDashboardButton } from "../dashboards/components/CreateDashboardButton";
import { AnalysisPageLayout } from "./analysis-page-layout";
interface AnalysisLayoutClientProps {
@@ -15,14 +17,25 @@ export function AnalysisLayoutClient({ children, params }: AnalysisLayoutClientP
// Determine active tab based on pathname
let activeId = "dashboards"; // default
if (pathname?.includes("/charts") || pathname?.includes("/chart-builder")) {
if (pathname?.includes("/charts")) {
activeId = "charts";
} else if (pathname?.includes("/dashboards") || pathname?.includes("/dashboard/")) {
activeId = "dashboards";
}
// Show CTA button based on current page
const isDashboardsPage = pathname?.includes("/dashboards") && !pathname?.includes("/dashboard/");
const isChartsPage = pathname?.includes("/charts");
let cta;
if (isDashboardsPage) {
cta = <CreateDashboardButton environmentId={environmentId} />;
} else if (isChartsPage) {
cta = <CreateChartButton environmentId={environmentId} />;
}
return (
<AnalysisPageLayout pageTitle="Analysis" activeId={activeId} environmentId={environmentId}>
<AnalysisPageLayout pageTitle="Analysis" activeId={activeId} environmentId={environmentId} cta={cta}>
{children}
</AnalysisPageLayout>
);

View File

@@ -0,0 +1,106 @@
"use client";
import { CopyIcon, PencilIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDashboard } from "../../../types/analysis";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
import { EditDashboardDialog } from "./EditDashboardDialog";
interface DashboardControlBarProps {
environmentId: string;
dashboard: TDashboard;
onDashboardUpdate?: () => void;
}
export const DashboardControlBar = ({
environmentId,
dashboard,
onDashboardUpdate,
}: DashboardControlBarProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const handleDeleteDashboard = async () => {
setIsDeleting(true);
try {
// TODO: Implement deleteDashboardAction when available
// const result = await deleteDashboardAction({ environmentId, dashboardId: dashboard.id });
// if (result?.data) {
// router.push(`/environments/${environmentId}/analysis/dashboards`);
// toast.success("Dashboard deleted successfully");
// } else {
// toast.error(result?.serverError || "Failed to delete dashboard");
// }
toast.error("Delete functionality coming soon");
} catch (error: any) {
toast.error(error.message || "Failed to delete dashboard");
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
}
};
const handleDuplicate = async () => {
// TODO: Implement duplicate functionality
toast.success("Duplicate functionality coming soon");
};
const iconActions = [
{
icon: PencilIcon,
tooltip: t("common.edit"),
onClick: () => {
setIsEditDialogOpen(true);
},
isVisible: true,
},
{
icon: CopyIcon,
tooltip: t("common.duplicate"),
onClick: handleDuplicate,
isVisible: true,
},
{
icon: TrashIcon,
tooltip: t("common.delete"),
onClick: () => {
setDeleteDialogOpen(true);
},
isVisible: true,
},
];
return (
<>
<IconBar actions={iconActions} />
<DeleteDialog
deleteWhat="Dashboard"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={handleDeleteDashboard}
text="Are you sure you want to delete this dashboard? This action cannot be undone."
isDeleting={isDeleting}
/>
<EditDashboardDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
dashboardId={dashboard.id}
environmentId={environmentId}
initialName={dashboard.name}
initialDescription={dashboard.description}
onSuccess={() => {
setIsEditDialogOpen(false);
onDashboardUpdate?.();
router.refresh();
}}
/>
</>
);
};

View File

@@ -1,15 +1,14 @@
"use client";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "react-hot-toast";
import { Button } from "@/modules/ui/components/button";
import { updateDashboardAction } from "../../../actions";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { TDashboard } from "../../../types/analysis";
import { CreateChartButton } from "../../../charts/components/CreateChartButton";
import { DashboardWidget } from "./DashboardWidget";
import { EditDashboardDialog } from "./EditDashboardDialog";
import { DashboardControlBar } from "./DashboardControlBar";
interface DashboardDetailClientProps {
dashboard: TDashboard;
@@ -18,36 +17,10 @@ interface DashboardDetailClientProps {
export function DashboardDetailClient({ dashboard: initialDashboard, environmentId }: DashboardDetailClientProps) {
const router = useRouter();
const [dashboard, setDashboard] = useState(initialDashboard);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [dashboard] = useState(initialDashboard);
const isEmpty = dashboard.widgets.length === 0;
const handlePublish = async () => {
setIsPublishing(true);
try {
const result = await updateDashboardAction({
environmentId,
dashboardId: dashboard.id,
status: "published",
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to publish dashboard");
return;
}
toast.success("Dashboard published successfully!");
setDashboard({ ...dashboard, status: "published" });
router.refresh();
} catch (error: any) {
toast.error(error.message || "Failed to publish dashboard");
} finally {
setIsPublishing(false);
}
};
const handleEditSuccess = () => {
const handleDashboardUpdate = () => {
router.refresh();
};
@@ -65,67 +38,45 @@ export function DashboardDetailClient({ dashboard: initialDashboard, environment
};
return (
<div className="min-h-[calc(100vh-120px)] bg-gray-100 p-6">
{/* Dashboard Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-900">{dashboard.name}</h1>
{dashboard.description && (
<p className="mt-1 text-sm text-gray-500">{dashboard.description}</p>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="text-brand-dark border-brand-dark/20 bg-white">
{dashboard.status === "published" ? "Published" : "Draft"}
</Button>
{dashboard.status === "draft" && (
<Button size="sm" onClick={handlePublish} loading={isPublishing}>
Publish
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setIsEditDialogOpen(true)}>
Edit dashboard
</Button>
</div>
</div>
{isEmpty ? (
// Empty State
<div className="flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/50">
<div className="mb-4 rounded-full bg-gray-100 p-4">
<div className="h-12 w-12 rounded-md bg-gray-300 opacity-20" />
</div>
<h3 className="text-lg font-medium text-gray-900">No Data</h3>
<p className="mt-2 max-w-sm text-center text-gray-500">
There is currently no information to display. Add charts to build your dashboard.
</p>
<Link href="../chart-builder">
<Button className="mt-6" variant="default">
<PlusIcon className="mr-2 h-4 w-4" />
Create Chart
</Button>
</Link>
</div>
) : (
// Grid Layout - Render widgets dynamically
<div className="grid grid-cols-12 gap-6">
{dashboard.widgets.map((widget) => (
<div key={widget.id} className={getColSpan(widget.layout.w)}>
<DashboardWidget widget={widget} environmentId={environmentId} />
<PageContentWrapper>
<GoBackButton url={`/environments/${environmentId}/analysis/dashboards`} />
<PageHeader
pageTitle={dashboard.name}
cta={
<DashboardControlBar
environmentId={environmentId}
dashboard={dashboard}
onDashboardUpdate={handleDashboardUpdate}
/>
}>
{dashboard.description && (
<p className="mt-2 text-sm text-gray-500">{dashboard.description}</p>
)}
</PageHeader>
<section className="pt-6 pb-24">
{isEmpty ? (
// Empty State
<div className="flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/50">
<div className="mb-4 rounded-full bg-gray-100 p-4">
<div className="h-12 w-12 rounded-md bg-gray-300 opacity-20" />
</div>
))}
</div>
)}
<EditDashboardDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
dashboardId={dashboard.id}
environmentId={environmentId}
initialName={dashboard.name}
initialDescription={dashboard.description}
onSuccess={handleEditSuccess}
/>
</div>
<h3 className="text-lg font-medium text-gray-900">No Data</h3>
<p className="mt-2 max-w-sm text-center text-gray-500">
There is currently no information to display. Add charts to build your dashboard.
</p>
<CreateChartButton environmentId={environmentId} />
</div>
) : (
// Grid Layout - Render widgets dynamically
<div className="grid grid-cols-12 gap-6">
{dashboard.widgets.map((widget) => (
<div key={widget.id} className={getColSpan(widget.layout.w)}>
<DashboardWidget widget={widget} environmentId={environmentId} />
</div>
))}
</div>
)}
</section>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,8 @@
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// This layout bypasses the analysis layout, allowing the dashboard page to have its own layout
return children;
}

View File

@@ -0,0 +1,76 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/modules/ui/components/button";
import { createDashboardAction } from "../../actions";
import { CreateDashboardDialog } from "./CreateDashboardDialog";
interface CreateDashboardButtonProps {
environmentId: string;
}
export function CreateDashboardButton({ environmentId }: CreateDashboardButtonProps) {
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [dashboardName, setDashboardName] = useState("");
const [dashboardDescription, setDashboardDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
const handleCreateDashboard = () => {
setIsCreateDialogOpen(true);
};
const handleCreate = async () => {
if (!dashboardName.trim()) {
toast.error("Please enter a dashboard name");
return;
}
setIsCreating(true);
try {
const result = await createDashboardAction({
environmentId,
name: dashboardName.trim(),
description: dashboardDescription.trim() || undefined,
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to create dashboard");
return;
}
toast.success("Dashboard created successfully!");
setIsCreateDialogOpen(false);
setDashboardName("");
setDashboardDescription("");
// Navigate to the new dashboard
router.push(`/environments/${environmentId}/analysis/dashboard/${result.data.id}`);
} catch (error: any) {
toast.error(error.message || "Failed to create dashboard");
} finally {
setIsCreating(false);
}
};
return (
<>
<Button onClick={handleCreateDashboard}>
<PlusIcon className="mr-2 h-4 w-4" />
Create Dashboard
</Button>
<CreateDashboardDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
dashboardName={dashboardName}
onDashboardNameChange={setDashboardName}
dashboardDescription={dashboardDescription}
onDashboardDescriptionChange={setDashboardDescription}
onCreate={handleCreate}
isCreating={isCreating}
/>
</>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { TDashboard } from "../../types/analysis";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
interface DashboardDropdownMenuProps {
environmentId: string;
dashboard: TDashboard;
disabled?: boolean;
deleteDashboard: (dashboardId: string) => void;
}
export const DashboardDropdownMenu = ({
environmentId,
dashboard,
disabled,
deleteDashboard,
}: DashboardDropdownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDeleteDashboard = async (dashboardId: string) => {
setLoading(true);
try {
// TODO: Implement deleteDashboardAction
// await deleteDashboardAction({ dashboardId });
deleteDashboard(dashboardId);
toast.success("Dashboard deleted successfully");
} catch (error) {
toast.error("Error deleting dashboard");
} finally {
setLoading(false);
}
};
return (
<div
id={`${dashboard.name.toLowerCase().split(" ").join("-")}-dashboard-actions`}
data-testid="dashboard-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}
onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open options</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/analysis/dashboard/${dashboard.id}`}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
// TODO: Implement duplicate functionality
toast.success("Duplicate functionality coming soon");
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Dashboard"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteDashboard(dashboard.id)}
text="Are you sure you want to delete this dashboard? This action cannot be undone."
isDeleting={loading}
/>
</div>
);
};

View File

@@ -1,22 +1,11 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { MoreHorizontalIcon, PlusIcon, SearchIcon, StarIcon } from "lucide-react";
import { format, formatDistanceToNow } from "date-fns";
import { BarChart3Icon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import { createDashboardAction } from "../../actions";
import { TDashboard } from "../../types/analysis";
import { CreateDashboardDialog } from "./CreateDashboardDialog";
import { DashboardDropdownMenu } from "./DashboardDropdownMenu";
interface DashboardsListClientProps {
dashboards: TDashboard[];
@@ -27,137 +16,82 @@ export function DashboardsListClient({
dashboards: initialDashboards,
environmentId,
}: DashboardsListClientProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [dashboards] = useState(initialDashboards);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [dashboardName, setDashboardName] = useState("");
const [dashboardDescription, setDashboardDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [dashboards, setDashboards] = useState(initialDashboards);
const filteredDashboards = dashboards.filter((dashboard) =>
dashboard.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleCreateDashboard = () => {
setIsCreateDialogOpen(true);
};
const handleCreate = async () => {
if (!dashboardName.trim()) {
toast.error("Please enter a dashboard name");
return;
}
setIsCreating(true);
try {
const result = await createDashboardAction({
environmentId,
name: dashboardName.trim(),
description: dashboardDescription.trim() || undefined,
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to create dashboard");
return;
}
toast.success("Dashboard created successfully!");
setIsCreateDialogOpen(false);
setDashboardName("");
setDashboardDescription("");
// Navigate to the new dashboard
router.push(`/environments/${environmentId}/analysis/dashboard/${result.data.id}`);
} catch (error: any) {
toast.error(error.message || "Failed to create dashboard");
} finally {
setIsCreating(false);
}
const deleteDashboard = (dashboardId: string) => {
setDashboards(dashboards.filter((d) => d.id !== dashboardId));
};
return (
<div className="flex h-full flex-col">
{/* Header / Actions */}
<div className="flex items-center justify-between border-gray-200 px-6 py-4">
<div className="flex items-center gap-4"></div>
<Button onClick={handleCreateDashboard}>
<PlusIcon className="mr-2 h-4 w-4" />
Dashboard
</Button>
<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>
{/* Table Content */}
<div className="flex-1 overflow-auto bg-gray-50 pt-6">
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
<table className="w-full text-left text-sm text-gray-600">
<thead className="bg-gray-50 text-xs font-semibold uppercase text-gray-500">
<tr>
<th className="border-b border-gray-200 px-6 py-3">Name</th>
<th className="border-b border-gray-200 px-6 py-3">Status</th>
<th className="border-b border-gray-200 px-6 py-3">Owners</th>
<th className="border-b border-gray-200 px-6 py-3">Last Modified</th>
<th className="border-b border-gray-200 px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredDashboards.map((dashboard) => (
<tr key={dashboard.id} className="group hover:bg-gray-50">
<td className="px-6 py-4 font-medium text-gray-900">
<div className="flex items-center gap-2">
{/* Star Icon - Active state simulation */}
<StarIcon
className={`h-4 w-4 ${dashboard.isFavorite ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`}
/>
<Link href={`dashboard/${dashboard.id}`}>
<span className="cursor-pointer hover:underline">{dashboard.name}</span>
</Link>
</div>
</td>
<td className="px-6 py-4">
<span className="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{dashboard.status === "published" ? "Published" : "Draft"}
</span>
</td>
<td className="px-6 py-4">{dashboard.owners.map((u) => u.name).join(", ") || "-"}</td>
<td className="px-6 py-4">
{formatDistanceToNow(new Date(dashboard.lastModified), { addSuffix: true })}
</td>
<td className="px-6 py-4 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100">
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
{filteredDashboards.length === 0 && (
<div className="p-12 text-center text-gray-500">No dashboards found.</div>
)}
</div>
</div>
<CreateDashboardDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
dashboardName={dashboardName}
onDashboardNameChange={setDashboardName}
dashboardDescription={dashboardDescription}
onDashboardDescriptionChange={setDashboardDescription}
onCreate={handleCreate}
isCreating={isCreating}
/>
{filteredDashboards.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">No dashboards found.</p>
) : (
<>
{filteredDashboards.map((dashboard) => (
<Link
key={dashboard.id}
href={`dashboard/${dashboard.id}`}
className="grid h-12 w-full cursor-pointer grid-cols-8 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
<BarChart3Icon className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{dashboard.name}</div>
{dashboard.description && (
<div className="ph-no-capture text-xs font-medium text-slate-500">
{dashboard.description}
</div>
)}
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{dashboard.chartCount}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{dashboard.createdByName || "-"}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{format(new Date(dashboard.createdAt), "do 'of' MMMM, yyyy")}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(new Date(dashboard.updatedAt), {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div
className="col-span-1 my-auto flex items-center justify-end pr-6"
onClick={(e) => e.stopPropagation()}>
<DashboardDropdownMenu
environmentId={environmentId}
dashboard={dashboard}
deleteDashboard={deleteDashboard}
/>
</div>
</Link>
))}
</>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getUser } from "@/lib/user/service";
import { TDashboard, TChart } from "../types/analysis";
/**
@@ -27,23 +28,38 @@ export const getDashboards = reactCache(async (environmentId: string): Promise<T
},
});
// Fetch user names for createdBy fields
const userIds = [...new Set(dashboards.map((d) => d.createdBy).filter(Boolean) as string[])];
const users = await Promise.all(userIds.map((id) => getUser(id)));
const userMap = new Map(users.filter(Boolean).map((u) => [u!.id, u!.name]));
// Transform to match TDashboard type
return dashboards.map((dashboard) => ({
id: dashboard.id,
name: dashboard.name,
description: dashboard.description || undefined,
status: dashboard.status,
owners: [], // TODO: Fetch owners if needed
lastModified: dashboard.updatedAt.toISOString(),
isFavorite: false, // TODO: Add favorite functionality if needed
widgets: dashboard.widgets.map((widget) => ({
id: widget.id,
type: widget.type as "chart" | "markdown" | "header" | "divider",
title: widget.title || undefined,
chartId: widget.chartId || undefined,
layout: widget.layout as { x: number; y: number; w: number; h: number },
})),
}));
return dashboards.map((dashboard) => {
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
const createdByName = dashboard.createdBy ? userMap.get(dashboard.createdBy) : undefined;
return {
id: dashboard.id,
name: dashboard.name,
description: dashboard.description || undefined,
status: dashboard.status,
owners: [], // TODO: Fetch owners if needed
lastModified: dashboard.updatedAt.toISOString(),
createdAt: dashboard.createdAt.toISOString(),
updatedAt: dashboard.updatedAt.toISOString(),
createdBy: dashboard.createdBy || undefined,
createdByName,
chartCount,
isFavorite: false, // TODO: Add favorite functionality if needed
widgets: dashboard.widgets.map((widget) => ({
id: widget.id,
type: widget.type as "chart" | "markdown" | "header" | "divider",
title: widget.title || undefined,
chartId: widget.chartId || undefined,
layout: widget.layout as { x: number; y: number; w: number; h: number },
})),
};
});
});
/**
@@ -64,17 +80,30 @@ export const getCharts = reactCache(async (environmentId: string): Promise<TChar
},
});
// Fetch user names for createdBy fields
const userIds = [...new Set(charts.map((c) => c.createdBy).filter(Boolean) as string[])];
const users = await Promise.all(userIds.map((id) => getUser(id)));
const userMap = new Map(users.filter(Boolean).map((u) => [u!.id, u!.name]));
// Transform to match TChart type
return charts.map((chart) => ({
id: chart.id,
name: chart.name,
type: chart.type as TChart["type"],
dataset: "FeedbackRecords", // TODO: Make this dynamic if needed
owners: [], // TODO: Fetch owners if needed
lastModified: chart.updatedAt.toISOString(),
dashboardIds: chart.widgets.map((widget) => widget.dashboardId),
config: (chart.config as Record<string, any>) || {},
}));
return charts.map((chart) => {
const createdByName = chart.createdBy ? userMap.get(chart.createdBy) : undefined;
return {
id: chart.id,
name: chart.name,
type: chart.type as TChart["type"],
dataset: "FeedbackRecords", // TODO: Make this dynamic if needed
owners: [], // TODO: Fetch owners if needed
lastModified: chart.updatedAt.toISOString(),
createdAt: chart.createdAt.toISOString(),
updatedAt: chart.updatedAt.toISOString(),
createdBy: chart.createdBy || undefined,
createdByName,
dashboardIds: chart.widgets.map((widget) => widget.dashboardId),
config: (chart.config as Record<string, any>) || {},
};
});
});
/**
@@ -113,6 +142,8 @@ export const getDashboard = reactCache(
return null;
}
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
return {
id: dashboard.id,
name: dashboard.name,
@@ -120,6 +151,11 @@ export const getDashboard = reactCache(
status: dashboard.status,
owners: [], // TODO: Fetch owners if needed
lastModified: dashboard.updatedAt.toISOString(),
createdAt: dashboard.createdAt.toISOString(),
updatedAt: dashboard.updatedAt.toISOString(),
createdBy: dashboard.createdBy || undefined,
createdByName: undefined, // Will be fetched if needed
chartCount,
isFavorite: false, // TODO: Add favorite functionality if needed
widgets: dashboard.widgets.map((widget) => ({
id: widget.id,
@@ -129,12 +165,12 @@ export const getDashboard = reactCache(
layout: widget.layout as { x: number; y: number; w: number; h: number },
chart: widget.chart
? {
id: widget.chart.id,
name: widget.chart.name,
type: widget.chart.type as TChart["type"],
query: widget.chart.query as Record<string, any>,
config: (widget.chart.config as Record<string, any>) || {},
}
id: widget.chart.id,
name: widget.chart.name,
type: widget.chart.type as TChart["type"],
query: widget.chart.query as Record<string, any>,
config: (widget.chart.config as Record<string, any>) || {},
}
: undefined,
})),
};

View File

@@ -12,6 +12,11 @@ export interface TDashboard {
status: TDashboardStatus;
owners: TAnalysisUser[];
lastModified: string; // ISO Date string
createdAt: string; // ISO Date string
updatedAt: string; // ISO Date string
createdBy?: string; // User ID
createdByName?: string; // User name for display
chartCount: number;
isFavorite: boolean;
widgets: TDashboardWidget[];
}
@@ -54,6 +59,10 @@ export interface TChart {
dataset: string;
owners: TAnalysisUser[];
lastModified: string;
createdAt: string; // ISO Date string
updatedAt: string; // ISO Date string
createdBy?: string; // User ID
createdByName?: string; // User name for display
dashboardIds: string[];
config: Record<string, any>; // Flexible config for specific chart props
}

View File

@@ -38,6 +38,7 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
const [inputValue, setInputValue] = React.useState("");
const [position, setPosition] = React.useState<{ top: number; left: number; width: number } | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const isSelectingRef = React.useRef(false);
// Track if changes are user-initiated (not from value prop)
const isUserInitiatedRef = React.useRef(false);
@@ -144,9 +145,8 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
className={`relative overflow-visible bg-white ${disabled ? "cursor-not-allowed opacity-50" : ""}`}>
<div
ref={containerRef}
className={`border-input ring-offset-background group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2 ${
disabled ? "pointer-events-none" : "focus-within:ring-ring"
}`}>
className={`border-input ring-offset-background group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2 ${disabled ? "pointer-events-none" : "focus-within:ring-ring"
}`}>
<div className="flex flex-wrap gap-1">
{selected.map((option) => (
<Badge key={option.value} className="rounded-md">
@@ -171,7 +171,12 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onBlur={(e) => {
// Don't close if we're selecting an option
if (!isSelectingRef.current) {
setOpen(false);
}
}}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
@@ -186,7 +191,7 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
globalThis.window !== undefined &&
createPortal(
<div
className="absolute z-[100]"
className="absolute z-[9999]"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
@@ -201,12 +206,18 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
isSelectingRef.current = true;
}}
onSelect={() => {
if (disabled) return;
isUserInitiatedRef.current = true; // Mark as user-initiated
setSelected((prev) => [...prev, option]);
setInputValue("");
// Reset the flag after a short delay to allow the selection to complete
setTimeout(() => {
isSelectingRef.current = false;
setOpen(false);
}, 100);
}}
className="cursor-pointer">
{option.label}