Files
triggerdotdev--trigger.dev/apps/webapp/app/components/query/QueryEditor.tsx
T
Chris Arderne 7673c46a02 chore(webapp): scope component effect synchronization (#4727)
## Summary

Scopes React Compiler diagnostics for component and hook effects that
intentionally synchronize with navigation, submissions, browser APIs,
streams, timers, or authoritative server values. Each suppression stays
on the reported synchronization call rather than disabling analysis for
the component.
2026-08-20 09:59:43 +01:00

1452 lines
52 KiB
TypeScript

import { ArrowDownTrayIcon, CalendarIcon, PencilSquareIcon } from "@heroicons/react/20/solid";
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { DialogClose } from "@radix-ui/react-dialog";
import { useFetcher } from "@remix-run/react";
import { IconChartHistogram } from "@tabler/icons-react";
import { formatDurationNanoseconds } from "@trigger.dev/core/v3";
import { Clipboard } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { flushSync } from "react-dom";
import { useTypedFetcher } from "remix-typedjson";
import simplur from "simplur";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { ChartConfigPanel, defaultChartConfig } from "~/components/code/ChartConfigPanel";
import { autoFormatSQL, TSQLEditor } from "~/components/code/TSQLEditor";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import {
QueryWidget,
type BigNumberConfiguration,
type ChartConfiguration,
type QueryWidgetConfig,
type QueryWidgetData,
} from "~/components/metrics/QueryWidget";
import { SaveToDashboardDialog } from "~/components/metrics/SaveToDashboardDialog";
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
} from "~/components/primitives/Popover";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
import { QueryHelpSidebar } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/QueryHelpSidebar";
import { QueryHistoryPopover } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/QueryHistoryPopover";
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
import type { action as titleAction } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title";
import type { QueryScope } from "~/services/queryService.server";
import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport";
import { organizationBillingPath } from "~/utils/pathBuilder";
import { listableQuerySchemas } from "~/v3/querySchemas";
import { useFeatures } from "~/hooks/useFeatures";
/** Convert a Date or ISO string to ISO string format */
function toISOString(value: Date | string): string {
if (typeof value === "string") {
return value;
}
return value.toISOString();
}
// Type for the query action response
type QueryActionResponse = {
error: string | null;
rows: Record<string, unknown>[] | null;
columns: OutputColumnMetadata[] | null;
stats: { elapsed_ns: string } | null;
hiddenColumns: string[] | null;
reachedMaxRows: boolean | null;
explainOutput: string | null;
generatedSql: string | null;
queryId?: string | null;
periodClipped: number | null;
maxQueryPeriod?: number;
};
type QueryEditorMode =
| { type: "standalone" }
| { type: "dashboard-add"; dashboardId: string; dashboardName: string }
| {
type: "dashboard-edit";
dashboardId: string;
dashboardName: string;
widgetId: string;
widgetName: string;
};
/** Data passed to the save render prop */
export type QueryEditorSaveData = {
title: string;
query: string;
config: QueryWidgetConfig;
};
export type QueryEditorProps = {
// Default values - used to initialize state
defaultQuery: string;
defaultScope: QueryScope;
defaultPeriod: string;
defaultTimeFilter?: { period?: string; from?: string; to?: string };
defaultResultsView?: "table" | "graph" | "bignumber";
defaultChartConfig?: ChartConfiguration;
defaultBigNumberConfig?: BigNumberConfiguration;
/** Initial result data to display (e.g., when editing an existing widget) */
defaultData?: QueryWidgetData;
// Other required data
history: QueryHistoryItem[];
isAdmin: boolean;
maxRows: number;
// The URL to post query execution requests to
queryActionUrl: string;
// Mode determines NavBar and save behavior
mode: QueryEditorMode;
// Max period days (from plan)
maxPeriodDays?: number;
// Render prop for save functionality - receives current data, returns ReactNode
save?: (data: QueryEditorSaveData) => ReactNode;
onClose?: () => void;
};
/** Handle for imperatively setting the query from outside */
interface QueryEditorFormHandle {
setQuery: (query: string) => void;
setScope: (scope: QueryScope) => void;
getQuery: () => string;
setTimeFilter: (filter: { period?: string; from?: string; to?: string }) => void;
}
/** Self-contained query editor with form - isolates query state from parent */
const QueryEditorForm = forwardRef<
QueryEditorFormHandle,
{
defaultPeriod: string;
defaultQuery: string;
defaultScope: QueryScope;
defaultTimeFilter?: { period?: string; from?: string; to?: string };
history: QueryHistoryItem[];
fetcher: ReturnType<typeof useTypedFetcher<QueryActionResponse>>;
isAdmin: boolean;
queryActionUrl: string;
maxPeriodDays?: number;
onQuerySubmit?: () => void;
onHistorySelected?: (item: QueryHistoryItem) => void;
}
>(function QueryEditorForm(
{
defaultPeriod,
defaultQuery,
defaultScope,
defaultTimeFilter,
history,
fetcher,
isAdmin,
queryActionUrl,
maxPeriodDays,
onQuerySubmit,
onHistorySelected,
},
ref
) {
const isLoading = fetcher.state === "submitting" || fetcher.state === "loading";
const { queueMetricsQueryTables } = useFeatures();
const schemas = useMemo(
() => listableQuerySchemas({ includeQueueMetrics: queueMetricsQueryTables }),
[queueMetricsQueryTables]
);
const [query, setQuery] = useState(defaultQuery);
const [scope, setScope] = useState<QueryScope>(defaultScope);
const formRef = useRef<HTMLFormElement>(null);
const prevFetcherState = useRef(fetcher.state);
// Notify parent when query is submitted (for title generation)
useEffect(() => {
if (prevFetcherState.current !== "submitting" && fetcher.state === "submitting") {
onQuerySubmit?.();
}
prevFetcherState.current = fetcher.state;
}, [fetcher.state, onQuerySubmit]);
// Get time filter values - initialize from props (which may come from history)
const [period, setPeriod] = useState<string | undefined>(defaultTimeFilter?.period);
const [from, setFrom] = useState<string | undefined>(defaultTimeFilter?.from);
const [to, setTo] = useState<string | undefined>(defaultTimeFilter?.to);
// Check if the query contains triggered_at in a WHERE clause
// This disables the time filter UI since the user is filtering in their query
const queryHasTriggeredAt = /\bWHERE\b[\s\S]*\btriggered_at\b/i.test(query);
// Expose methods to parent for external query setting (history, AI, examples)
useImperativeHandle(
ref,
() => ({
setQuery,
setScope,
getQuery: () => query,
setTimeFilter: (filter: { period?: string; from?: string; to?: string }) => {
setPeriod(filter.period);
setFrom(filter.from);
setTo(filter.to);
},
}),
[query]
);
const handleHistorySelected = useCallback(
(item: QueryHistoryItem) => {
setQuery(item.query);
setScope(item.scope);
// Apply time filter from history item
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
setPeriod(item.filterPeriod ?? undefined);
setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined);
setTo(item.filterTo ? toISOString(item.filterTo) : undefined);
// Notify parent about history selection (for title)
onHistorySelected?.(item);
},
[onHistorySelected]
);
return (
<div className="flex h-full flex-col gap-2 bg-white pb-2 dark:bg-background-deep">
<TSQLEditor
defaultValue={query}
onChange={setQuery}
schema={schemas}
linterEnabled={true}
showCopyButton={true}
showClearButton={true}
className="min-h-0 flex-1"
/>
<fetcher.Form
ref={formRef}
method="post"
action={queryActionUrl}
className="flex items-center justify-between gap-2 px-2"
>
<input type="hidden" name="query" value={query} />
<input type="hidden" name="scope" value={scope} />
{/* Pass time filter values to action */}
<input type="hidden" name="period" value={period ?? ""} />
<input type="hidden" name="from" value={from ?? ""} />
<input type="hidden" name="to" value={to ?? ""} />
<QueryHistoryPopover history={history} onQuerySelected={handleHistorySelected} />
<div className="flex items-center gap-1.5">
{isAdmin && (
<Button
type="submit"
name="explain"
value="true"
variant="minimal/small"
disabled={isLoading || !query.trim()}
>
Explain
</Button>
)}
<ScopeFilter value={scope} onValueChange={setScope} shortcut={{ key: "e" }} />
{queryHasTriggeredAt ? (
<SimpleTooltip
asChild
button={
<span>
<Button
variant="secondary/small"
disabled={true}
type="button"
LeadingIcon={CalendarIcon}
leadingIconClassName="text-text-dimmed/70"
>
Set in query
</Button>
</span>
}
className="max-w-48"
content="Your query includes a WHERE clause with triggered_at so this filter is disabled."
/>
) : (
<TimeFilter
defaultPeriod={defaultPeriod}
labelName="Triggered"
hideLabel
valueClassName="text-text-bright"
period={period}
from={from}
to={to}
shortcut={{ key: "d" }}
applyShortcut={{ key: "enter", enabledOnInputElements: true }}
onValueChange={(values) => {
flushSync(() => {
setPeriod(values.period);
setFrom(values.from);
setTo(values.to);
});
if (formRef.current) {
fetcher.submit(formRef.current);
}
}}
maxPeriodDays={maxPeriodDays}
/>
)}
<Button
type="submit"
variant="primary/small"
disabled={isLoading || !query.trim()}
shortcut={{ modifiers: ["mod"], key: "enter", enabledOnInputElements: true }}
className={
isLoading
? "relative text-transparent! **:border-transparent! **:text-transparent!"
: undefined
}
>
{isLoading ? (
<>
Query
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Spinner className="size-4 opacity-50" color="white" />
</span>
</>
) : (
"Query"
)}
</Button>
</div>
</fetcher.Form>
</div>
);
});
export function QueryEditor({
defaultQuery,
defaultScope,
defaultPeriod,
defaultTimeFilter,
defaultResultsView = "table",
defaultChartConfig: initialChartConfig,
defaultBigNumberConfig: initialBigNumberConfig,
defaultData,
history,
isAdmin,
maxRows,
queryActionUrl,
mode,
maxPeriodDays,
save,
onClose,
}: QueryEditorProps) {
const fetcher = useTypedFetcher<QueryActionResponse>();
// Use defaultData as initial results, then switch to fetcher data once a query is run
const fetcherResults = fetcher.data;
const results = useMemo(
() =>
fetcherResults ??
(defaultData
? {
error: null,
rows: defaultData.rows,
columns: defaultData.columns,
stats: null,
hiddenColumns: null,
reachedMaxRows: false,
explainOutput: null,
generatedSql: null,
queryId: null,
periodClipped: null,
}
: null),
[defaultData, fetcherResults]
);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const editorRef = useRef<QueryEditorFormHandle>(null);
const [prettyFormatting, setPrettyFormatting] = useState(true);
const [resultsView, setResultsView] = useState<"table" | "graph" | "bignumber">(
defaultResultsView
);
const [chartConfig, setChartConfig] = useState<ChartConfiguration>(
initialChartConfig ?? defaultChartConfig
);
const [bigNumberConfig, setBigNumberConfig] = useState<BigNumberConfiguration>(
initialBigNumberConfig ?? { column: "", aggregation: "sum", abbreviate: true }
);
const [sidebarTab, setSidebarTab] = useState<string>("ai");
const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null);
// Save to dashboard dialog state (only for standalone mode)
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
// Title generation state
const titleFetcher = useFetcher<typeof titleAction>();
const isTitleLoading = titleFetcher.state !== "idle";
const generatedTitle = titleFetcher.data?.title;
const [historyTitle, setHistoryTitle] = useState<string | null>(
history.length > 0 ? (history[0].title ?? null) : null
);
// For edit mode, use the widget name as initial title
const initialTitle = mode.type === "dashboard-edit" ? mode.widgetName : null;
const [editModeTitle, setEditModeTitle] = useState<string | null>(initialTitle);
// User-set title (takes priority, and disables AI regeneration)
const [userTitle, setUserTitle] = useState<string | null>(null);
// Effective title: user title > edit mode title > history title > generated title
const queryTitle =
userTitle ??
(mode.type === "dashboard-edit"
? (editModeTitle ?? historyTitle ?? generatedTitle ?? null)
: (historyTitle ?? generatedTitle ?? null));
// Track if user has manually set a title (disables AI regeneration)
const hasUserTitle = userTitle !== null;
// Track whether we should generate a title for the current results
const [shouldGenerateTitle, setShouldGenerateTitle] = useState(false);
// Trigger title generation when query succeeds (only for new queries, not history, not if user set title)
useEffect(() => {
if (
results?.rows &&
!results.error &&
results.queryId &&
shouldGenerateTitle &&
!historyTitle &&
!hasUserTitle &&
titleFetcher.state === "idle"
) {
const currentQuery = editorRef.current?.getQuery();
if (currentQuery) {
titleFetcher.submit(
{ query: currentQuery, queryId: results.queryId },
{
method: "POST",
action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-title`,
encType: "application/json",
}
);
setShouldGenerateTitle(false);
}
}
}, [
results,
shouldGenerateTitle,
historyTitle,
hasUserTitle,
titleFetcher,
organization.slug,
project.slug,
environment.slug,
]);
const handleTryFixError = useCallback((errorMessage: string) => {
setSidebarTab("ai");
setAiFixRequest((prev) => ({
prompt: `Fix this query error: ${errorMessage}`,
key: (prev?.key ?? 0) + 1,
}));
}, []);
// Handle time filter changes from AI
const handleTimeFilterChange = useCallback((filter: AITimeFilter) => {
editorRef.current?.setTimeFilter({
period: filter.period,
from: filter.from,
to: filter.to,
});
}, []);
const isLoading = fetcher.state === "submitting" || fetcher.state === "loading";
// Stable string key of result column names (types excluded — they can vary between runs)
const columnNamesKey = results?.columns ? results.columns.map((c) => c.name).join(",") : "";
// Use a ref so the effect can read chartConfig without re-firing on every config tweak
const chartConfigRef = useRef(chartConfig);
chartConfigRef.current = chartConfig;
// Reset chart config only when a column referenced by the current config is no
// longer present in the results. This means:
// - Re-running the same query preserves all settings
// - Adding new columns preserves settings (existing config columns still exist)
// - Removing/renaming a column used in the config triggers a reset
useEffect(() => {
if (!columnNamesKey || initialChartConfig) return;
const config = chartConfigRef.current;
const configColumns = [
config.xAxisColumn,
...config.yAxisColumns,
config.groupByColumn,
config.sortByColumn,
].filter((col): col is string => col != null);
// Nothing configured yet — ChartConfigPanel will auto-select defaults
if (configColumns.length === 0) return;
const availableColumns = new Set(columnNamesKey.split(","));
if (configColumns.some((col) => !availableColumns.has(col))) {
setChartConfig(defaultChartConfig);
}
}, [columnNamesKey, initialChartConfig]);
const handleChartConfigChange = useCallback((config: ChartConfiguration) => {
setChartConfig(config);
}, []);
// Handle query submission - prepare for title generation
const handleQuerySubmit = useCallback(() => {
setHistoryTitle(null); // Clear history title when running a new query
setEditModeTitle(null); // Clear edit mode title when running a new query
// Only enable title generation if user hasn't manually set a title
// userTitle persists across query edits once set
setShouldGenerateTitle(!hasUserTitle);
}, [hasUserTitle]);
// Handle history selection - use existing title if available
const handleHistorySelected = useCallback((item: QueryHistoryItem) => {
setHistoryTitle(item.title ?? null);
setEditModeTitle(null);
setUserTitle(null); // Clear user title when selecting from history
setShouldGenerateTitle(false); // Don't generate title for history items
}, []);
// Handle user renaming the title
const handleRenameTitle = useCallback((newTitle: string) => {
setUserTitle(newTitle);
// Clear other title sources since user has explicitly set the title
setHistoryTitle(null);
setEditModeTitle(null);
}, []);
// Compute current save data for the save render prop
const currentQuery = editorRef.current?.getQuery() ?? "";
const saveData: QueryEditorSaveData = {
title: queryTitle ?? "Untitled Query",
query: currentQuery,
config:
resultsView === "table"
? { type: "table", prettyFormatting, sorting: [] }
: resultsView === "bignumber"
? { type: "bignumber", ...bigNumberConfig }
: { type: "chart", ...chartConfig },
};
// Render NavBar based on mode
const renderNavBar = () => {
switch (mode.type) {
case "standalone":
return (
<NavBar>
<PageTitle title="Query" />
</NavBar>
);
case "dashboard-add":
return (
<NavBar>
<PageTitle title={`Add chart to ${mode.dashboardName}`} />
<PageAccessories>
<Button
variant="secondary/small"
onClick={onClose}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
// className="pl-1"
>
Cancel
</Button>
</PageAccessories>
</NavBar>
);
case "dashboard-edit":
return (
<NavBar>
<PageTitle title={`Editing "${mode.widgetName}"`} />
<PageAccessories>
<Button
variant="secondary/small"
onClick={onClose}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
// className="pl-1"
>
Cancel
</Button>
</PageAccessories>
</NavBar>
);
}
};
return (
<PageContainer>
{renderNavBar()}
<PageBody scrollable={false}>
<ResizablePanelGroup
orientation="horizontal"
className="h-full max-h-full bg-background-bright"
>
<ResizablePanel id="query-main" className="h-full">
<ResizablePanelGroup orientation="vertical" className="h-full overflow-hidden">
{/* Query editor - isolated component to prevent re-renders */}
<ResizablePanel
id="query-editor"
min="100px"
default="300px"
className="overflow-hidden"
>
<QueryEditorForm
ref={editorRef}
defaultPeriod={defaultPeriod}
defaultQuery={defaultQuery}
defaultScope={defaultScope}
defaultTimeFilter={defaultTimeFilter}
history={history}
fetcher={fetcher}
isAdmin={isAdmin}
queryActionUrl={queryActionUrl}
maxPeriodDays={maxPeriodDays}
onQuerySubmit={handleQuerySubmit}
onHistorySelected={handleHistorySelected}
/>
</ResizablePanel>
<ResizableHandle id="query-editor-handle" />
{/* Results */}
<ResizablePanel
id="query-results"
min="200px"
className="overflow-hidden bg-background-bright"
>
<ClientTabs
value={resultsView}
onValueChange={(v) => setResultsView(v as "table" | "graph" | "bignumber")}
className="grid h-full max-h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden"
>
<ClientTabsList
variant="underline"
className="shrink-0 overflow-hidden pl-3 pr-1"
>
<ClientTabsTrigger value="table" variant="underline" layoutId="results-tabs">
Table
</ClientTabsTrigger>
<ClientTabsTrigger
value="graph"
variant="underline"
layoutId="results-tabs"
disabled={!results?.rows || results.rows.length === 0}
>
Graph
</ClientTabsTrigger>
<ClientTabsTrigger
value="bignumber"
variant="underline"
layoutId="results-tabs"
disabled={!results?.rows || results.rows.length === 0}
>
Big number
</ClientTabsTrigger>
{results?.rows ? (
<div className="flex flex-1 items-center justify-end gap-2 overflow-hidden border-b border-grid-dimmed pl-3">
<div className="flex items-center gap-2 overflow-hidden truncate">
{results.reachedMaxRows ? (
<SimpleTooltip
buttonClassName="text-warning text-xs"
button={`${results.rows.length.toLocaleString()} Results`}
content={`Results are limited to ${maxRows.toLocaleString()} rows maximum.`}
/>
) : (
<span className="text-xs text-text-dimmed">
{results.rows.length > 0
? `${results.rows.length.toLocaleString()} Results`
: "Results"}
</span>
)}
{results?.stats && (
<span className="text-xs text-text-dimmed">
{formatDurationNanoseconds(parseInt(results.stats.elapsed_ns, 10))}
</span>
)}
</div>
<div className="flex items-center gap-1">
{results?.rows && results?.columns && results.rows.length > 0 && (
<ExportResultsButton rows={results.rows} columns={results.columns} />
)}
{resultsView === "table" && (
<Switch
variant="tertiary/small"
label="Pretty formatting"
checked={prettyFormatting}
onCheckedChange={setPrettyFormatting}
/>
)}
</div>
</div>
) : null}
</ClientTabsList>
<ClientTabsContent value="table" className="m-0 min-h-0 overflow-y-hidden">
{isLoading ? (
<div className="flex items-center gap-2 p-4 text-sm text-text-dimmed">
<Spinner className="size-4" />
<span>Executing query</span>
</div>
) : results?.error ? (
<div className="p-4">
<pre className="whitespace-pre-wrap text-sm text-red-400">
{results.error}
</pre>
<Button
variant="tertiary/small"
className="mt-3"
LeadingIcon={AISparkleIcon}
onClick={() => handleTryFixError(results.error!)}
>
Try fix error
</Button>
</div>
) : results?.explainOutput ? (
<div className="flex h-full flex-col gap-4 overflow-auto p-3">
{results.generatedSql && (
<div>
<Header3 className="mb-2">Generated ClickHouse SQL</Header3>
<div className="overflow-auto rounded border border-grid-dimmed bg-background-deep p-3">
<pre className="whitespace-pre font-mono text-xs text-text-bright">
{results.generatedSql}
</pre>
</div>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col">
<Header3 className="mb-2">Query Execution Plan</Header3>
<div className="min-h-0 flex-1 overflow-auto rounded border border-grid-dimmed bg-background-deep p-3">
<pre className="whitespace-pre font-mono text-xs text-text-bright">
{results.explainOutput}
</pre>
</div>
</div>
</div>
) : results?.rows && results?.columns ? (
<div
className={`grid h-full max-h-full overflow-hidden bg-background-bright ${
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
? "grid-rows-[auto_1fr]"
: "grid-rows-[1fr]"
}`}
>
<QueryResultsCallouts
hiddenColumns={results.hiddenColumns}
periodClipped={results.periodClipped}
organizationSlug={organization.slug}
/>
<div className="overflow-hidden">
<QueryWidget
className="border-0"
showTableHeaderOnEmpty
title={
<QueryTitle
isTitleLoading={isTitleLoading}
title={queryTitle}
onRename={handleRenameTitle}
/>
}
query={editorRef.current?.getQuery() ?? defaultQuery}
data={{
rows: results.rows,
columns: results.columns,
}}
config={{
type: "table",
prettyFormatting,
sorting: [],
}}
accessory={
mode.type === "standalone" ? (
<Button
variant="primary/small"
onClick={() => setIsSaveDialogOpen(true)}
>
Add to dashboard
</Button>
) : save ? (
save(saveData)
) : undefined
}
/>
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3">
<IconChartHistogram className="size-16 text-secondary" />
<Paragraph className="max-w-48 text-center text-text-dimmed">
Run a query to visualize the results.
</Paragraph>
</div>
)}
</ClientTabsContent>
<ClientTabsContent
value="graph"
className={`m-0 grid h-full max-h-full min-h-0 overflow-hidden bg-background-bright ${
results?.rows &&
results.rows.length > 0 &&
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
? "grid-rows-[auto_1fr]"
: "grid-rows-[1fr]"
}`}
>
{results?.rows && results?.columns && results.rows.length > 0 ? (
<>
<QueryResultsCallouts
hiddenColumns={results.hiddenColumns}
periodClipped={results.periodClipped}
organizationSlug={organization.slug}
/>
<ResultsChart
rows={results.rows}
columns={results.columns}
query={editorRef.current?.getQuery() ?? defaultQuery}
chartConfig={chartConfig}
onChartConfigChange={handleChartConfigChange}
queryTitle={queryTitle}
isTitleLoading={isTitleLoading}
onRenameTitle={handleRenameTitle}
accessory={
mode.type === "standalone" ? (
<Button
variant="primary/small"
onClick={() => setIsSaveDialogOpen(true)}
>
Add to dashboard
</Button>
) : save ? (
save(saveData)
) : undefined
}
/>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3">
<IconChartHistogram className="size-16 text-secondary" />
<Paragraph className="max-w-48 text-center text-text-dimmed">
Run a query to visualize the results.
</Paragraph>
</div>
)}
</ClientTabsContent>
<ClientTabsContent
value="bignumber"
className={`m-0 grid h-full max-h-full min-h-0 overflow-hidden bg-background-bright ${
results?.rows &&
results.rows.length > 0 &&
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
? "grid-rows-[auto_1fr]"
: "grid-rows-[1fr]"
}`}
>
{results?.rows && results?.columns && results.rows.length > 0 ? (
<>
<QueryResultsCallouts
hiddenColumns={results.hiddenColumns}
periodClipped={results.periodClipped}
organizationSlug={organization.slug}
/>
<ResultsBigNumber
rows={results.rows}
columns={results.columns}
query={editorRef.current?.getQuery() ?? defaultQuery}
bigNumberConfig={bigNumberConfig}
onBigNumberConfigChange={setBigNumberConfig}
queryTitle={queryTitle}
isTitleLoading={isTitleLoading}
onRenameTitle={handleRenameTitle}
accessory={
mode.type === "standalone" ? (
<Button
variant="primary/small"
onClick={() => setIsSaveDialogOpen(true)}
>
Add to dashboard
</Button>
) : save ? (
save(saveData)
) : undefined
}
/>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3">
<IconChartHistogram className="size-16 text-secondary" />
<Paragraph className="max-w-48 text-center text-text-dimmed">
Run a query to visualize the results.
</Paragraph>
</div>
)}
</ClientTabsContent>
</ClientTabs>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle id="query-handle" />
<ResizablePanel
id="query-help"
min="380px"
default="400px"
max="800px"
className="w-full"
>
<QueryHelpSidebar
onTryExample={(exampleQuery, exampleScope) => {
editorRef.current?.setQuery(exampleQuery);
editorRef.current?.setScope(exampleScope);
}}
onQueryGenerated={(query) => {
const formatted = autoFormatSQL(query);
editorRef.current?.setQuery(formatted);
}}
onTimeFilterChange={handleTimeFilterChange}
getCurrentQuery={() => editorRef.current?.getQuery() ?? ""}
activeTab={sidebarTab}
onTabChange={setSidebarTab}
aiFixRequest={aiFixRequest}
/>
</ResizablePanel>
</ResizablePanelGroup>
</PageBody>
{mode.type === "standalone" && (
<SaveToDashboardDialog
title={queryTitle ?? "Untitled Query"}
query={editorRef.current?.getQuery() ?? ""}
config={
resultsView === "table"
? { type: "table", prettyFormatting, sorting: [] }
: resultsView === "bignumber"
? { type: "bignumber", ...bigNumberConfig }
: { type: "chart", ...chartConfig }
}
isOpen={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
/>
)}
</PageContainer>
);
}
function QueryTitle({
isTitleLoading,
title,
onRename,
}: {
isTitleLoading: boolean;
title: string | null;
onRename?: (newTitle: string) => void;
}) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(title ?? "");
// Update rename value when title changes
useEffect(() => {
// oxlint-disable-next-line react/react-compiler -- This effect intentionally synchronizes local state after an external or lifecycle change.
setRenameValue(title ?? "");
}, [title]);
if (isTitleLoading)
return (
<span className="flex items-center gap-2 text-text-dimmed">
<Spinner className="size-4" /> Generating title
</span>
);
return (
<>
<span className="group flex items-center gap-1">
{title ?? "Results"}
{onRename && title && (
<Button
variant="minimal/small"
LeadingIcon={PencilSquareIcon}
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
className="opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => {
setRenameValue(title);
setIsDialogOpen(true);
}}
/>
)}
</span>
{onRename && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Rename chart</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (renameValue.trim()) {
onRename(renameValue.trim());
setIsDialogOpen(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Chart title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</>
);
}
function ExportResultsButton({
rows,
columns,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
}) {
const [isOpen, setIsOpen] = useState(false);
const handleCopyCSV = () => {
const csv = rowsToCSV(rows, columns);
navigator.clipboard.writeText(csv);
setIsOpen(false);
};
const handleExportCSV = () => {
const csv = rowsToCSV(rows, columns);
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
downloadFile(csv, `query-results-${timestamp}.csv`, "text/csv");
setIsOpen(false);
};
const handleCopyJSON = () => {
const json = rowsToJSON(rows);
navigator.clipboard.writeText(json);
setIsOpen(false);
};
const handleExportJSON = () => {
const json = rowsToJSON(rows);
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
downloadFile(json, `query-results-${timestamp}.json`, "application/json");
setIsOpen(false);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverArrowTrigger variant="minimal" isOpen={isOpen}>
Export
</PopoverArrowTrigger>
<PopoverContent className="min-w-40 p-1" align="end">
<div className="flex flex-col gap-1">
<PopoverMenuItem
icon={Clipboard}
title="Copy CSV"
onClick={handleCopyCSV}
className="pl-1"
/>
<PopoverMenuItem icon={ArrowDownTrayIcon} title="Export CSV" onClick={handleExportCSV} />
<PopoverMenuItem
icon={Clipboard}
title="Copy JSON"
onClick={handleCopyJSON}
className="pl-1"
/>
<PopoverMenuItem
icon={ArrowDownTrayIcon}
title="Export JSON"
onClick={handleExportJSON}
/>
</div>
</PopoverContent>
</Popover>
);
}
function QueryResultsCallouts({
hiddenColumns,
periodClipped,
organizationSlug,
}: {
hiddenColumns: string[] | null | undefined;
periodClipped: number | null;
organizationSlug: string;
}) {
const hasCallouts = (hiddenColumns && hiddenColumns.length > 0) || periodClipped;
if (!hasCallouts) {
return null;
}
return (
<div className="flex flex-col gap-2 px-2 pt-2">
{hiddenColumns && hiddenColumns.length > 0 && (
<Callout variant="warning" className="shrink-0 text-sm">
<code>SELECT *</code> returns core columns only. To include{" "}
<span className="font-mono text-xs">{hiddenColumns.join(", ")}</span>, add them to your
SELECT explicitly.
</Callout>
)}
{periodClipped && (
<Callout
variant="pricing"
cta={
<LinkButton
variant="primary/small"
to={organizationBillingPath({ slug: organizationSlug })}
>
Upgrade
</LinkButton>
}
className="items-center"
>
{simplur`Results are limited to the last ${periodClipped} day[|s] based on your plan.`}
</Callout>
)}
</div>
);
}
function hasQueryResultsCallouts(
hiddenColumns: string[] | null | undefined,
periodClipped: number | null
): boolean {
return (hiddenColumns && hiddenColumns.length > 0) || !!periodClipped;
}
function ResultsChart({
rows,
columns,
query,
chartConfig,
onChartConfigChange,
queryTitle,
isTitleLoading,
onRenameTitle,
accessory,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
query?: string;
chartConfig: ChartConfiguration;
onChartConfigChange: (config: ChartConfiguration) => void;
queryTitle: string | null;
isTitleLoading: boolean;
onRenameTitle?: (newTitle: string) => void;
accessory?: ReactNode;
}) {
return (
<ResizablePanelGroup className="overflow-hidden">
<ResizablePanel id="chart-results">
<div className="h-full overflow-hidden bg-background-bright">
<QueryWidget
className="border-0"
title={
<QueryTitle
isTitleLoading={isTitleLoading}
title={queryTitle}
onRename={onRenameTitle}
/>
}
query={query}
data={{
rows,
columns,
}}
config={{
type: "chart",
...chartConfig,
}}
accessory={accessory}
/>
</div>
</ResizablePanel>
<ResizableHandle id="chart-split" />
<ResizablePanel id="chart-config" min="50px" default="200px">
<ChartConfigPanel columns={columns} config={chartConfig} onChange={onChartConfigChange} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
function isNumericColumnType(type: string): boolean {
return (
type.startsWith("Int") ||
type.startsWith("UInt") ||
type.startsWith("Float") ||
type.startsWith("Decimal") ||
type.startsWith("Nullable(Int") ||
type.startsWith("Nullable(UInt") ||
type.startsWith("Nullable(Float") ||
type.startsWith("Nullable(Decimal")
);
}
function ResultsBigNumber({
rows,
columns,
query,
bigNumberConfig,
onBigNumberConfigChange,
queryTitle,
isTitleLoading,
onRenameTitle,
accessory,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
query?: string;
bigNumberConfig: BigNumberConfiguration;
onBigNumberConfigChange: (config: BigNumberConfiguration) => void;
queryTitle: string | null;
isTitleLoading: boolean;
onRenameTitle?: (newTitle: string) => void;
accessory?: ReactNode;
}) {
// Auto-select first numeric column if none selected
const firstNumericColumn = columns.find((column) => isNumericColumnType(column.type));
useEffect(() => {
if (!bigNumberConfig.column && firstNumericColumn) {
onBigNumberConfigChange({ ...bigNumberConfig, column: firstNumericColumn.name });
}
}, [bigNumberConfig, firstNumericColumn, onBigNumberConfigChange]);
return (
<ResizablePanelGroup className="overflow-hidden">
<ResizablePanel id="bignumber-results">
<div className="h-full overflow-hidden bg-background-bright">
<QueryWidget
className="border-0"
title={
<QueryTitle
isTitleLoading={isTitleLoading}
title={queryTitle}
onRename={onRenameTitle}
/>
}
query={query}
data={{
rows,
columns,
}}
config={{
type: "bignumber",
...bigNumberConfig,
}}
accessory={accessory}
/>
</div>
</ResizablePanel>
<ResizableHandle id="bignumber-split" />
<ResizablePanel id="bignumber-config" min="50px" default="200px">
<BigNumberConfigPanel
columns={columns}
config={bigNumberConfig}
onChange={onBigNumberConfigChange}
/>
</ResizablePanel>
</ResizablePanelGroup>
);
}
const bigNumberAggregationOptions = [
{ value: "sum", label: "Sum" },
{ value: "avg", label: "Average" },
{ value: "count", label: "Count" },
{ value: "min", label: "Min" },
{ value: "max", label: "Max" },
{ value: "first", label: "First" },
{ value: "last", label: "Last" },
] as const;
const bigNumberSortOptions = [
{ value: "", label: "Unsorted" },
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
] as const;
function BigNumberConfigPanel({
columns,
config,
onChange,
}: {
columns: OutputColumnMetadata[];
config: BigNumberConfiguration;
onChange: (config: BigNumberConfiguration) => void;
}) {
const numericColumns = columns.filter((c) => isNumericColumnType(c.type));
const allColumns = columns;
// For count aggregation, any column works; for others, prefer numeric
const availableColumns = config.aggregation === "count" ? allColumns : numericColumns;
return (
<div className="flex h-full flex-col gap-2 overflow-y-auto p-2">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<Paragraph variant="extra-small" className="text-text-bright">
Column
</Paragraph>
<Select
value={config.column || ""}
setValue={(value) => onChange({ ...config, column: value })}
variant="tertiary/small"
dropdownIcon={true}
items={availableColumns.map((c) => ({ value: c.name, label: c.name }))}
text={(value) => value || "Select column"}
placeholder="Select column"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))
}
</Select>
</div>
<div className="flex flex-col gap-1">
<Paragraph variant="extra-small" className="text-text-bright">
Sort order
</Paragraph>
<Select
value={config.sortDirection ?? ""}
setValue={(value) =>
onChange({
...config,
sortDirection: value === "" ? undefined : (value as "asc" | "desc"),
})
}
variant="tertiary/small"
dropdownIcon={true}
items={[...bigNumberSortOptions]}
text={(value) =>
bigNumberSortOptions.find((o) => o.value === value)?.label ?? "Unsorted"
}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))
}
</Select>
</div>
<div className="flex flex-col gap-1">
<Paragraph variant="extra-small" className="text-text-bright">
Aggregation
</Paragraph>
<Select
value={config.aggregation}
setValue={(value) =>
onChange({ ...config, aggregation: value as BigNumberConfiguration["aggregation"] })
}
variant="tertiary/small"
dropdownIcon={true}
items={[...bigNumberAggregationOptions]}
text={(value) =>
bigNumberAggregationOptions.find((o) => o.value === value)?.label ?? value
}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))
}
</Select>
</div>
<div className="flex items-center justify-between">
<Switch
label="Abbreviate large values"
labelPosition="right"
variant="small"
checked={config.abbreviate ?? false}
onCheckedChange={(checked) => onChange({ ...config, abbreviate: checked })}
/>
</div>
<div className="flex flex-col gap-1">
<Paragraph variant="extra-small" className="text-text-bright">
Prefix
</Paragraph>
<Input
value={config.prefix ?? ""}
onChange={(e) => onChange({ ...config, prefix: e.target.value || undefined })}
placeholder="e.g. $"
variant="small"
/>
</div>
<div className="flex flex-col gap-1">
<Paragraph variant="extra-small" className="text-text-bright">
Suffix
</Paragraph>
<Input
value={config.suffix ?? ""}
onChange={(e) => onChange({ ...config, suffix: e.target.value || undefined })}
placeholder="e.g. ms"
variant="small"
/>
</div>
</div>
</div>
);
}