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