1445 lines
51 KiB
TypeScript
1445 lines
51 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,
|
|
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 { querySchemas } from "~/v3/querySchemas";
|
|
|
|
/** 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;
|
|
};
|
|
|
|
export 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 [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-background-deep pb-2">
|
|
<TSQLEditor
|
|
defaultValue={query}
|
|
onChange={setQuery}
|
|
schema={querySchemas}
|
|
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 =
|
|
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);
|
|
|
|
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(() => {
|
|
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 numericColumns = columns.filter((c) => isNumericColumnType(c.type));
|
|
|
|
useEffect(() => {
|
|
if (!bigNumberConfig.column && numericColumns.length > 0) {
|
|
onBigNumberConfigChange({ ...bigNumberConfig, column: numericColumns[0].name });
|
|
}
|
|
}, [columns]);
|
|
|
|
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>
|
|
);
|
|
}
|