7673c46a02
## 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.
1452 lines
52 KiB
TypeScript
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>
|
|
);
|
|
}
|