import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid"; import { ClipboardIcon } from "@heroicons/react/24/outline"; import { ChartBarIcon } from "@heroicons/react/24/solid"; import { type OutputColumnMetadata } from "@internal/tsql"; import { DialogClose } from "@radix-ui/react-dialog"; import { IconBraces, IconChartHistogram, IconFileTypeCsv } from "@tabler/icons-react"; import { assertNever } from "assert-never"; import { Maximize2 } from "lucide-react"; import { useCallback, useRef, useState, type ReactNode } from "react"; import { z } from "zod"; import { Card } from "~/components/primitives/charts/Card"; import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { cn } from "~/utils/cn"; import { rowsToCSV, rowsToJSON } from "~/utils/dataExport"; import { QueryResultsChart } from "../code/QueryResultsChart"; import { TSQLResultsTable } from "../code/TSQLResultsTable"; import { Button } from "../primitives/Buttons"; import { Callout } from "../primitives/Callout"; import { BigNumberCard } from "../primitives/charts/BigNumberCard"; import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog"; import { Input } from "../primitives/Input"; import { InputGroup } from "../primitives/InputGroup"; import { Label } from "../primitives/Label"; import { LoadingBarDivider } from "../primitives/LoadingBarDivider"; import { Popover, PopoverContent, PopoverMenuItem, PopoverVerticalEllipseTrigger, } from "../primitives/Popover"; const ChartType = z.union([z.literal("bar"), z.literal("line")]); export type ChartType = z.infer; const SortDirection = z.union([z.literal("asc"), z.literal("desc")]); export type SortDirection = z.infer; const AggregationType = z.union([ z.literal("sum"), z.literal("avg"), z.literal("count"), z.literal("min"), z.literal("max"), ]); export type AggregationType = z.infer; const chartConfigOptions = { chartType: ChartType, xAxisColumn: z.string().nullable(), yAxisColumns: z.string().array(), groupByColumn: z.string().nullable(), stacked: z.boolean(), sortByColumn: z.string().nullable(), sortDirection: SortDirection, aggregation: AggregationType, seriesColors: z.record(z.string()).optional(), }; const ChartConfiguration = z.object({ ...chartConfigOptions }); export type ChartConfiguration = z.infer; const BigNumberAggregationType = z.union([ z.literal("sum"), z.literal("avg"), z.literal("count"), z.literal("min"), z.literal("max"), z.literal("first"), z.literal("last"), ]); export type BigNumberAggregationType = z.infer; const BigNumberSortDirection = z.union([z.literal("asc"), z.literal("desc")]); const bigNumberConfigOptions = { column: z.string(), aggregation: BigNumberAggregationType, sortDirection: BigNumberSortDirection.optional(), abbreviate: z.boolean().default(false), prefix: z.string().optional(), suffix: z.string().optional(), }; const BigNumberConfiguration = z.object({ ...bigNumberConfigOptions }); export type BigNumberConfiguration = z.infer; export const QueryWidgetConfig = z.discriminatedUnion("type", [ z.object({ type: z.literal("table"), prettyFormatting: z.boolean().default(true), sorting: z .array( z.object({ desc: z.boolean(), id: z.string(), }) ) .default([]), }), z.object({ type: z.literal("chart"), ...chartConfigOptions, }), z.object({ type: z.literal("bignumber"), ...bigNumberConfigOptions, }), z.object({ type: z.literal("title"), }), ]); export type QueryWidgetConfig = z.infer; /** Result data containing rows and column metadata */ export type QueryWidgetData = { rows: Record[]; columns: OutputColumnMetadata[]; }; /** Widget configuration with optional result data (used for edit callbacks) */ export type WidgetData = { title: string; query: string; display: QueryWidgetConfig; /** The current result data from the widget */ resultData?: QueryWidgetData; }; export type QueryWidgetProps = { title: ReactNode; /** String title for rename dialog (optional - if not provided, rename won't be available) */ titleString?: string; /** The TSQL query string (used for "Copy query" in the menu) */ query?: string; isLoading?: boolean; error?: string; data: QueryWidgetData; config: QueryWidgetConfig; /** The effective time range for the query (used to show full x-axis on time-based charts) */ timeRange?: { from: string; to: string }; accessory?: ReactNode; isResizing?: boolean; isDraggable?: boolean; /** Additional className applied to the Card wrapper */ className?: string; /** Callback when edit is clicked. Receives the current data. */ onEdit?: (data: QueryWidgetData) => void; /** Callback when rename is clicked. Receives the new title. */ onRename?: (newTitle: string) => void; /** Callback when delete is clicked. */ onDelete?: () => void; /** Callback when duplicate is clicked. Receives the current data. */ onDuplicate?: (data: QueryWidgetData) => void; /** When true, show table column headers even when there are no rows */ showTableHeaderOnEmpty?: boolean; /** Column names to hide from table display but keep in row data (useful for linking) */ hiddenColumns?: string[]; }; export function QueryWidget({ title, titleString, query, accessory, isLoading, error, isResizing, isDraggable, className, onEdit, onRename, onDelete, onDuplicate, ...props }: QueryWidgetProps) { const [isFullscreen, setIsFullscreen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [renameValue, setRenameValue] = useState(titleString ?? ""); const containerRef = useRef(null); const hasEditActions = onEdit || onRename || onDelete || onDuplicate; const hasData = props.data.rows.length > 0; // "v" to toggle fullscreen on hovered widget useShortcutKeys({ shortcut: { key: "v" }, action: useCallback(() => { const isHovered = containerRef.current?.matches(":hover"); if (!isFullscreen && !isHovered) return; setIsFullscreen((prev) => !prev); }, [isFullscreen]), }); const copyToClipboard = useCallback((text: string) => { navigator.clipboard.writeText(text); }, []); const copyQuery = useCallback(() => { if (query) { copyToClipboard(query); } }, [query, copyToClipboard]); const copyJSON = useCallback(() => { copyToClipboard(rowsToJSON(props.data.rows)); }, [props.data.rows, copyToClipboard]); const copyCSV = useCallback(() => { copyToClipboard(rowsToCSV(props.data.rows, props.data.columns)); }, [props.data, copyToClipboard]); return (
{title}
)}
); } type QueryWidgetBodyProps = { title: ReactNode; data: QueryWidgetData; config: QueryWidgetConfig; timeRange?: { from: string; to: string }; isFullscreen: boolean; setIsFullscreen: (open: boolean) => void; isLoading: boolean; showTableHeaderOnEmpty?: boolean; hiddenColumns?: string[]; }; function QueryWidgetBody({ title, data, config, timeRange, isFullscreen, setIsFullscreen, isLoading, showTableHeaderOnEmpty, hiddenColumns, }: QueryWidgetBodyProps) { const type = config.type; // Only show the loading state if we have no data yet (initial load). // During a reload with existing data, keep showing the current data // while the loading bar in the header indicates a refresh is in progress. const hasData = data.rows.length > 0; const showLoading = isLoading && !hasData; switch (type) { case "table": { return ( <> {title}
); } case "chart": { return ( <> setIsFullscreen(true)} isLoading={showLoading} /> {title}
); } case "bignumber": { return ( <> {title}
); } case "title": { // Title widgets are rendered by TitleWidget, not QueryWidget return null; } default: { assertNever(type); } } }