buster/apps/web/src/app/test/chart-playground/page.tsx

261 lines
11 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { AppCodeEditor } from '@/components/ui/inputs/AppCodeEditor';
import { yamlToJson } from '@/lib/yaml-to-json';
import { useRunSQL } from '@/api/buster_rest/sql/queryRequests';
import { BusterChart } from '@/components/ui/charts/BusterChart';
import {
ChartConfigPropsSchema,
type ChartConfigProps,
type DataResult
} from '@buster/server-shared/metrics';
import type { ZodError } from 'zod';
import type { RunSQLResponse } from '@/api/asset_interfaces';
import { useMemoizedFn } from '@/hooks';
import { Button } from '@/components/ui/buttons';
import { Input } from '@/components/ui/inputs';
import { AppSplitter } from '@/components/ui/layouts/AppSplitter';
type YamlifiedConfig = {
sql: string;
chartConfig: ChartConfigProps;
};
const initfile = `name: Top 10 Customers by Lifetime Value\ndescription: Shows the customers who have generated the highest total revenue over their entire relationship with the company\ntimeFrame: All time\nsql: \"SELECT \\n CONCAT(p.firstname, ' ', p.lastname) AS customer_name,\\n clv.metric_clv_all_time::numeric AS lifetime_value\\nFROM postgres.ont_ont.customer_all_time_clv clv\\nJOIN postgres.ont_ont.customer c ON clv.customerid = c.customerid\\nLEFT JOIN postgres.ont_ont.person p ON c.personid = p.businessentityid\\nWHERE p.firstname IS NOT NULL AND p.lastname IS NOT NULL\\nORDER BY clv.metric_clv_all_time::numeric DESC\\nLIMIT 10\\n\"\nchartConfig:\n selectedChartType: bar\n columnLabelFormats:\n customer_name:\n columnType: string\n style: string\n numberSeparatorStyle: null\n replaceMissingDataWith: null\n lifetime_value:\n columnType: number\n style: currency\n numberSeparatorStyle: ','\n minimumFractionDigits: 2\n maximumFractionDigits: 2\n replaceMissingDataWith: 0\n currency: USD\n barAndLineAxis:\n x:\n - customer_name\n y:\n - lifetime_value\n barLayout: horizontal\n`;
const initDataSourceId = 'cc3ef3bc-44ec-4a43-8dc4-681cae5c996a';
export default function ChartPlayground() {
// State management
const [config, setConfig] = useState<string>(initfile);
const [dataResponse, setDataResponse] = useState<RunSQLResponse | null>(null);
const [dataSourceId, setDataSourceId] = useState<string>(initDataSourceId);
// SQL mutation hook
const {
mutateAsync: runSQLMutation,
error: runSQLError,
isPending: isRunningSQL,
isSuccess: hasRunSQL,
reset: resetRunSQL
} = useRunSQL();
// Parse YAML config
const yamlifiedConfig = useMemo(() => {
if (!config.trim()) return null;
try {
return yamlToJson<YamlifiedConfig>(config);
} catch (error) {
return null;
}
}, [config]);
// Parse and validate chart configuration
const chartConfigParsed = useMemo(() => {
if (!yamlifiedConfig?.chartConfig) {
return { data: null, error: null };
}
const parsed = ChartConfigPropsSchema.safeParse(yamlifiedConfig.chartConfig);
return {
data: parsed.success ? parsed.data : null,
error: parsed.success ? null : (parsed.error as ZodError)
};
}, [yamlifiedConfig]);
// Derived values
const chartConfig = chartConfigParsed.data;
const chartConfigError = chartConfigParsed.error;
const data: DataResult = dataResponse?.data || [];
const columnMetadata = dataResponse?.data_metadata?.column_metadata || [];
const hasSQL = !!yamlifiedConfig?.sql;
const hasDataSourceId = !!dataSourceId.trim();
// SQL execution handler
const runSQL = useMemoizedFn(async () => {
if (!yamlifiedConfig?.sql || !hasDataSourceId) {
return;
}
try {
const res = await runSQLMutation({
sql: yamlifiedConfig.sql,
data_source_id: dataSourceId
});
setDataResponse(res);
} catch (error) {
// Error is handled by the hook
}
});
// Status checks
const isReadyToRun = hasSQL && hasDataSourceId;
const isReadyToChart = chartConfig && data.length > 0 && hasRunSQL;
// Setup hotkey for running SQL (meta+enter)
useHotkeys(
'meta+enter',
(event) => {
event.preventDefault();
if (isReadyToRun && !isRunningSQL) {
runSQL();
}
},
{
enabled: isReadyToRun && !isRunningSQL,
enableOnContentEditable: true,
enableOnFormTags: true
}
);
// Define the left panel content (code editor and controls)
const leftPanelContent = (
<div className="bg-background flex h-full w-full flex-col space-y-4 overflow-hidden rounded-lg border p-4">
<div className="h-full w-full overflow-hidden">
<AppCodeEditor value={config} onChange={setConfig} />
</div>
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-1">
<label className="w-fit min-w-28 text-sm font-medium">Data Source ID</label>
<Input value={dataSourceId || ''} onChange={(e) => setDataSourceId(e.target.value)} />
</div>
<Button block onClick={runSQL} loading={isRunningSQL} disabled={!hasSQL}>
<div className="flex items-center justify-center gap-2">
<span>Run SQL</span>
<span className="rounded bg-black/10 px-1.5 py-0.5 text-xs opacity-70"></span>
</div>
</Button>
</div>
</div>
);
// Define the right panel content (chart preview)
const rightPanelContent = (
<div className="bg-background flex h-full flex-col overflow-hidden rounded-lg border shadow-sm">
{/* Header */}
<div className="bg-muted/30 border-b px-6 py-4">
<h3 className="text-foreground text-lg font-semibold">Chart Preview</h3>
</div>
{/* Content Area */}
<div className="flex-1 space-y-6 overflow-y-auto p-6">
{/* Error States */}
{chartConfigError && (
<div className="rounded-md border border-red-200 bg-red-50 p-4">
<div className="mb-3 flex items-center space-x-2">
<div className="h-4 w-4 flex-shrink-0 rounded-full bg-red-400"></div>
<h4 className="text-sm font-semibold text-red-800">Chart Configuration Error</h4>
</div>
<pre className="overflow-x-auto rounded bg-red-100 p-3 text-xs whitespace-pre-wrap text-red-700">
{JSON.stringify(chartConfigError || {}, null, 2)}
</pre>
</div>
)}
{runSQLError && (
<div className="rounded-md border border-red-200 bg-red-50 p-4">
<div className="mb-3 flex items-center space-x-2">
<div className="h-4 w-4 flex-shrink-0 rounded-full bg-red-400"></div>
<h4 className="text-sm font-semibold text-red-800">SQL Execution Error</h4>
</div>
<pre className="overflow-x-auto rounded bg-red-100 p-3 text-xs whitespace-pre-wrap text-red-700">
{JSON.stringify(runSQLError || {}, null, 2)}
</pre>
</div>
)}
{/* Loading State */}
{isRunningSQL && (
<div className="rounded-md border border-blue-200 bg-blue-50 p-4">
<div className="flex items-center space-x-3">
<div className="h-5 w-5 flex-shrink-0 animate-spin rounded-full border-2 border-blue-400 border-t-transparent"></div>
<div>
<h4 className="text-sm font-semibold text-blue-800">Executing SQL Query</h4>
<p className="mt-1 text-xs text-blue-600">
Please wait while we process your query...
</p>
</div>
</div>
</div>
)}
{/* Checklist */}
{!(chartConfig && data && hasRunSQL && dataSourceId) && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-4">
<div className="mb-4 flex items-center space-x-2">
<div className="h-4 w-4 flex-shrink-0 rounded-full bg-amber-400"></div>
<h4 className="text-sm font-semibold text-amber-800">Setup Checklist</h4>
</div>
<ul className="space-y-2">
<li className="flex items-center space-x-3">
<div
className={`h-2 w-2 flex-shrink-0 rounded-full ${chartConfig ? 'bg-green-400' : 'bg-yellow-300'}`}></div>
<span
className={`text-sm ${chartConfig ? 'text-green-700 line-through' : 'text-amber-700'}`}>
Chart configuration is {chartConfig ? 'ready' : 'missing'}
</span>
</li>
<li className="flex items-center space-x-3">
<div
className={`h-2 w-2 flex-shrink-0 rounded-full ${data.length > 0 ? 'bg-green-400' : 'bg-yellow-300'}`}></div>
<span
className={`text-sm ${data.length > 0 ? 'text-green-700 line-through' : 'text-amber-700'}`}>
Data is {data.length > 0 ? 'available' : 'not available'}
</span>
</li>
<li className="flex items-center space-x-3">
<div
className={`h-2 w-2 flex-shrink-0 rounded-full ${hasRunSQL ? 'bg-green-400' : 'bg-yellow-300'}`}></div>
<span
className={`text-sm ${hasRunSQL ? 'text-green-700 line-through' : 'text-amber-700'}`}>
SQL has {hasRunSQL ? 'been executed' : 'not been run'}
</span>
</li>
<li className="flex items-center space-x-3">
<div
className={`h-2 w-2 flex-shrink-0 rounded-full ${dataSourceId ? 'bg-green-400' : 'bg-yellow-300'}`}></div>
<span
className={`text-sm ${dataSourceId ? 'text-green-700 line-through' : 'text-amber-700'}`}>
Data Source ID is {dataSourceId ? 'set' : 'not set'}
</span>
</li>
</ul>
</div>
)}
{/* Chart Display */}
{chartConfig && data && hasRunSQL && dataSourceId && (
<div className="rounded-md border bg-gradient-to-br from-green-50 to-blue-50 p-6">
<div className="mb-4 flex items-center space-x-2">
<div className="h-3 w-3 flex-shrink-0 animate-pulse rounded-full bg-green-500"></div>
<h4 className="text-sm font-semibold text-green-800">Chart Ready</h4>
</div>
<div className="rounded-lg bg-white p-4 shadow-sm">
<BusterChart {...chartConfig} data={data} columnMetadata={columnMetadata} />
</div>
</div>
)}
</div>
</div>
);
return (
<div className="h-screen w-screen p-4">
<AppSplitter
autoSaveId="chart-playground"
splitterClassName="mx-2"
defaultLayout={DEFAULT_LAYOUT}
className="h-full w-full"
leftPanelMinSize="200px"
rightPanelMinSize="200px"
leftChildren={leftPanelContent}
rightChildren={rightPanelContent}
preserveSide="left"
/>
</div>
);
}
const DEFAULT_LAYOUT = ['40%', 'auto'];