Fix(webapp): metrics UI improvements (#3063)
- Lots of small UI improvements - Toggle full screen charts with "V" shortcut <img width="3504" height="2286" alt="CleanShot 2026-02-14 at 20 06 30@2x" src="https://github.com/user-attachments/assets/32403661-06b3-4f6c-a074-243b3f43197d" /> <img width="362" height="222" alt="CleanShot 2026-02-14 at 20 06 58@2x" src="https://github.com/user-attachments/assets/ee4f5859-4b71-482e-9636-5abd78d9f623" /> <img width="434" height="360" alt="CleanShot 2026-02-14 at 20 06 53@2x" src="https://github.com/user-attachments/assets/44339348-f3f3-425b-bbf3-bb41ed6a8e59" /> <img width="460" height="310" alt="CleanShot 2026-02-14 at 20 06 48@2x" src="https://github.com/user-attachments/assets/eb3b6af6-b88a-4677-8a48-acfa6b768770" /> <img width="502" height="332" alt="CleanShot 2026-02-14 at 20 06 45@2x" src="https://github.com/user-attachments/assets/b788d78b-8f4a-4b18-94c8-487673f3ec7b" /> <img width="370" height="257" alt="CleanShot 2026-02-14 at 20 10 29@2x" src="https://github.com/user-attachments/assets/a81e8b14-b2eb-402c-bf3b-affd0d4fde26" />
This commit is contained in:
@@ -193,6 +193,12 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Metrics page</Header3>
|
||||
<Shortcut name="Toggle fullscreen chart">
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Schedules page</Header3>
|
||||
<Shortcut name="New schedule">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
|
||||
import { BarChart, CheckIcon, LineChart, Plus, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -6,6 +7,7 @@ import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { Select, SelectItem } from "../primitives/Select";
|
||||
import { Switch } from "../primitives/Switch";
|
||||
import SegmentedControl from "../primitives/SegmentedControl";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import {
|
||||
type AggregationType,
|
||||
@@ -234,54 +236,38 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2 p-2", className)}>
|
||||
<div className={cn("flex flex-col gap-3 p-2", className)}>
|
||||
{/* Chart Type */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<ConfigField label="Type">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
className={cn(
|
||||
"rounded-r-none border-b pl-1 pr-2",
|
||||
config.chartType === "bar" ? "border-indigo-500" : "border-transparent"
|
||||
)}
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => updateConfig({ chartType: "bar" })}
|
||||
LeadingIcon={BarChart}
|
||||
leadingIconClassName={
|
||||
config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"
|
||||
}
|
||||
>
|
||||
<span className={config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"}>
|
||||
Bar
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
className={cn(
|
||||
"rounded-l-none border-b pl-1 pr-2",
|
||||
config.chartType === "line" ? "border-indigo-500" : "border-transparent"
|
||||
)}
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => updateConfig({ chartType: "line" })}
|
||||
LeadingIcon={LineChart}
|
||||
leadingIconClassName={
|
||||
config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"}
|
||||
>
|
||||
Line
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
name="chartType"
|
||||
value={config.chartType}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart className="size-3" /> Bar
|
||||
</span>
|
||||
),
|
||||
value: "bar",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<LineChart className="size-3" /> Line
|
||||
</span>
|
||||
),
|
||||
value: "line",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ chartType: value as "bar" | "line" })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* X-Axis */}
|
||||
<ConfigField label="X-Axis">
|
||||
<Select
|
||||
@@ -529,9 +515,29 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
{/* Sort Direction (only when sorting) */}
|
||||
{config.sortByColumn && (
|
||||
<ConfigField label="Sort direction">
|
||||
<SortDirectionToggle
|
||||
direction={config.sortDirection}
|
||||
onChange={(direction) => updateConfig({ sortDirection: direction })}
|
||||
<SegmentedControl
|
||||
name="sortDirection"
|
||||
value={config.sortDirection}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortAscending className="size-3" /> Asc
|
||||
</span>
|
||||
),
|
||||
value: "asc",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortDescending className="size-3" /> Desc
|
||||
</span>
|
||||
),
|
||||
value: "desc",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ sortDirection: value as SortDirection })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
@@ -543,51 +549,12 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-text-dimmed">{label}</span>}
|
||||
{label && <span className="text-xs text-text-bright">{label}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortDirectionToggle({
|
||||
direction,
|
||||
onChange,
|
||||
}: {
|
||||
direction: SortDirection;
|
||||
onChange: (direction: SortDirection) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("asc")}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs transition-colors",
|
||||
direction === "asc"
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
|
||||
)}
|
||||
title="Ascending"
|
||||
>
|
||||
Asc
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("desc")}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs transition-colors",
|
||||
direction === "desc"
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
|
||||
)}
|
||||
title="Descending"
|
||||
>
|
||||
Desc
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesColorPicker({
|
||||
color,
|
||||
onColorChange,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { BarChart3, LineChart } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
import type { ChartConfig } from "~/components/primitives/charts/Chart";
|
||||
import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
|
||||
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
|
||||
import { aggregateValues } from "../primitives/charts/aggregation";
|
||||
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
@@ -947,20 +948,22 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
}, [isDateBased, xAxisTickFormatter, xAxisAngle]);
|
||||
|
||||
// Validation — all hooks must be above this point
|
||||
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;
|
||||
|
||||
if (!xAxisColumn) {
|
||||
return <EmptyState message="Select an X-axis column to display the chart" />;
|
||||
return <ChartBlankState icon={chartIcon} message="Select an X-axis column to display the chart" />;
|
||||
}
|
||||
|
||||
if (yAxisColumns.length === 0) {
|
||||
return <EmptyState message="Select a Y-axis column to display the chart" />;
|
||||
return <ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState message="No data to display" />;
|
||||
return <ChartBlankState icon={chartIcon} message="No data to display" />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState message="Unable to transform data for chart" />;
|
||||
return <ChartBlankState icon={chartIcon} message="Unable to transform data for chart" />;
|
||||
}
|
||||
|
||||
// Base x-axis props shared by all chart types
|
||||
@@ -1113,12 +1116,3 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[300px] items-center justify-center">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{message}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { IconFilter2, IconFilter2X } from "@tabler/icons-react";
|
||||
import { IconFilter2, IconFilter2X, IconTable } from "@tabler/icons-react";
|
||||
import { rankItem } from "@tanstack/match-sorter-utils";
|
||||
import {
|
||||
flexRender,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { formatDurationMilliseconds, MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { AlertCircle, ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { forwardRef, memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { EnvironmentLabel, EnvironmentSlug } from "~/components/environments/EnvironmentLabel";
|
||||
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
|
||||
@@ -37,7 +37,9 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
|
||||
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
|
||||
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { QueueName } from "../runs/v3/QueueName";
|
||||
@@ -412,7 +414,7 @@ function CellValueWrapper({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex-1"
|
||||
className="flex flex-1 items-center"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
@@ -718,15 +720,16 @@ function CopyableCell({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full items-center overflow-hidden px-2 py-1.5",
|
||||
"relative flex h-full w-full items-center overflow-hidden px-2",
|
||||
"bg-background-bright group-hover/row:bg-charcoal-750",
|
||||
"font-mono text-xs text-text-dimmed group-hover/row:text-text-bright",
|
||||
"[&_a:focus-visible]:underline [&_a:focus-visible]:underline-offset-[3px] [&_a:focus-visible]:outline-none",
|
||||
alignment === "right" && "justify-end"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span className="flex h-4 items-center truncate">{children}</span>
|
||||
<span className="flex items-center truncate">{children}</span>
|
||||
{isHovered && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
@@ -847,7 +850,7 @@ function HeaderCellContent({
|
||||
}}
|
||||
onMouseEnter={() => setIsFilterHovered(true)}
|
||||
onMouseLeave={() => setIsFilterHovered(false)}
|
||||
className="flex-shrink-0 rounded text-text-dimmed transition-colors hover:text-text-bright"
|
||||
className="flex-shrink-0 rounded text-text-dimmed transition-colors focus-custom hover:text-text-bright"
|
||||
title="Toggle column filters"
|
||||
>
|
||||
{showFilters ? <IconFilter2X className="size-4" /> : <IconFilter2 className="size-4" />}
|
||||
@@ -903,11 +906,14 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
columns,
|
||||
prettyFormatting = true,
|
||||
sorting: defaultSorting = [],
|
||||
showHeaderOnEmpty = false,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
prettyFormatting?: boolean;
|
||||
sorting?: SortingState;
|
||||
/** When true, show column headers + "No results" on empty data. When false, show a blank state icon. */
|
||||
showHeaderOnEmpty?: boolean;
|
||||
}) {
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -977,6 +983,10 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
|
||||
// Empty state
|
||||
if (rows.length === 0) {
|
||||
if (!showHeaderOnEmpty) {
|
||||
return <ChartBlankState icon={IconTable} message="No data to display" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full min-h-0 w-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
@@ -984,7 +994,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
>
|
||||
<table style={{ display: "grid" }}>
|
||||
<thead
|
||||
className="border-t border-grid-bright bg-background-bright"
|
||||
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
|
||||
style={{
|
||||
display: "grid",
|
||||
position: "sticky",
|
||||
@@ -1005,63 +1015,24 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
<HeaderCellContent
|
||||
alignment={meta?.alignment ?? "left"}
|
||||
tooltip={meta?.outputColumn.description}
|
||||
onFilterClick={() => {
|
||||
if (!showFilters) {
|
||||
setFocusFilterColumn(header.id);
|
||||
} else {
|
||||
setColumnFilters([]);
|
||||
}
|
||||
setShowFilters(!showFilters);
|
||||
}}
|
||||
showFilters={showFilters}
|
||||
hasActiveFilter={!!header.column.getFilterValue()}
|
||||
sortDirection={header.column.getIsSorted()}
|
||||
onSortClick={header.column.getToggleSortingHandler()}
|
||||
canSort={header.column.getCanSort()}
|
||||
>
|
||||
<HeaderCellContent alignment={meta?.alignment ?? "left"}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</HeaderCellContent>
|
||||
{/* Column resizer */}
|
||||
<div
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
|
||||
"opacity-0 group-hover/header:opacity-100",
|
||||
"bg-charcoal-600 hover:bg-indigo-500",
|
||||
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
|
||||
)}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{/* Filter row - shown when filters are toggled */}
|
||||
{showFilters && (
|
||||
<tr style={{ display: "flex", width: "100%" }}>
|
||||
{table.getHeaderGroups()[0]?.headers.map((header) => (
|
||||
<FilterCell
|
||||
key={`filter-${header.id}`}
|
||||
column={header.column}
|
||||
width={header.getSize()}
|
||||
shouldFocus={focusFilterColumn === header.id}
|
||||
onFocused={() => setFocusFilterColumn(null)}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody className="border-b border-grid-bright" style={{ display: "grid" }}>
|
||||
<tr style={{ display: "flex" }}>
|
||||
<td>
|
||||
<Paragraph variant="extra-small" className="p-4 text-text-dimmed">
|
||||
No results
|
||||
</Paragraph>
|
||||
<tbody style={{ display: "grid" }}>
|
||||
<tr style={{ display: "flex", width: "100%" }}>
|
||||
<td className="w-full px-3 py-6" colSpan={columns.length}>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<AlertCircle className="size-5 text-text-dimmed/50" />
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
This query returned no results
|
||||
</Paragraph>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -1171,6 +1142,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
|
||||
@@ -6,10 +6,12 @@ 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, useState, type ReactNode } from "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";
|
||||
@@ -142,6 +144,8 @@ export type QueryWidgetProps = {
|
||||
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. */
|
||||
@@ -150,6 +154,8 @@ export type QueryWidgetProps = {
|
||||
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;
|
||||
};
|
||||
|
||||
export function QueryWidget({
|
||||
@@ -161,6 +167,7 @@ export function QueryWidget({
|
||||
error,
|
||||
isResizing,
|
||||
isDraggable,
|
||||
className,
|
||||
onEdit,
|
||||
onRename,
|
||||
onDelete,
|
||||
@@ -171,10 +178,21 @@ export function QueryWidget({
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(titleString ?? "");
|
||||
const containerRef = useRef<HTMLDivElement>(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);
|
||||
}, []);
|
||||
@@ -194,8 +212,8 @@ export function QueryWidget({
|
||||
}, [props.data, copyToClipboard]);
|
||||
|
||||
return (
|
||||
<div className="group h-full">
|
||||
<Card className="h-full overflow-hidden px-0 pb-0">
|
||||
<div ref={containerRef} className="group h-full">
|
||||
<Card className={cn("h-full overflow-hidden px-0 pb-0", className)}>
|
||||
<Card.Header draggable={isDraggable}>
|
||||
<div className="flex items-center gap-1.5">{title}</div>
|
||||
<Card.Accessory>
|
||||
@@ -211,10 +229,14 @@ export function QueryWidget({
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
content="Maximize"
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Maximize
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
|
||||
</span>
|
||||
}
|
||||
asChild
|
||||
/>
|
||||
{accessory}
|
||||
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<PopoverVerticalEllipseTrigger
|
||||
isOpen={isMenuOpen}
|
||||
@@ -307,6 +329,7 @@ export function QueryWidget({
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{accessory}
|
||||
</Card.Accessory>
|
||||
</Card.Header>
|
||||
<LoadingBarDivider isLoading={isLoading ?? false} className="bg-transparent" />
|
||||
@@ -382,6 +405,7 @@ type QueryWidgetBodyProps = {
|
||||
isFullscreen: boolean;
|
||||
setIsFullscreen: (open: boolean) => void;
|
||||
isLoading: boolean;
|
||||
showTableHeaderOnEmpty?: boolean;
|
||||
};
|
||||
|
||||
function QueryWidgetBody({
|
||||
@@ -392,6 +416,7 @@ function QueryWidgetBody({
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
isLoading,
|
||||
showTableHeaderOnEmpty,
|
||||
}: QueryWidgetBodyProps) {
|
||||
const type = config.type;
|
||||
|
||||
@@ -410,6 +435,7 @@ function QueryWidgetBody({
|
||||
columns={data.columns}
|
||||
prettyFormatting={config.prettyFormatting}
|
||||
sorting={config.sorting}
|
||||
showHeaderOnEmpty={showTableHeaderOnEmpty}
|
||||
/>
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent
|
||||
@@ -423,6 +449,7 @@ function QueryWidgetBody({
|
||||
columns={data.columns}
|
||||
prettyFormatting={config.prettyFormatting}
|
||||
sorting={config.sorting}
|
||||
showHeaderOnEmpty={showTableHeaderOnEmpty}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||
import { IconChartHistogram } from "@tabler/icons-react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import {
|
||||
@@ -138,7 +138,11 @@ export function SaveToDashboardDialog({
|
||||
: "text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
<IconChartHistogram className="size-4 shrink-0 text-text-dimmed" />
|
||||
{selectedDashboardId === dashboard.friendlyId ? (
|
||||
<IconCheck className="size-4 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<span className="size-4 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{dashboard.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -51,6 +51,7 @@ const ClientTabs = React.forwardRef<
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
activationMode="manual"
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
@@ -96,6 +97,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex-1 basis-0",
|
||||
@@ -134,6 +136,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
@@ -143,7 +146,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -170,8 +173,9 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none focus-custom data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -188,8 +192,9 @@ const ClientTabsContent = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"mt-1 outline-none",
|
||||
className,
|
||||
"data-[state=inactive]:hidden"
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const sizes = {
|
||||
@@ -63,7 +64,7 @@ const variants = {
|
||||
type VariantType = keyof typeof variants;
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
value: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ import { cn } from "~/utils/cn";
|
||||
import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
|
||||
|
||||
const small =
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border transition uppercase";
|
||||
|
||||
const medium =
|
||||
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
small: cn(small, "border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60"),
|
||||
"small/bright": cn(small, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
medium: cn(medium, "group-hover:border-charcoal-550"),
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
@@ -54,10 +57,10 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
);
|
||||
}
|
||||
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
function keyString(key: string, isMac: boolean, variant: ShortcutKeyVariant) {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
@@ -86,9 +89,9 @@ function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "m
|
||||
function modifierString(
|
||||
modifier: Modifier,
|
||||
isMac: boolean,
|
||||
variant: "small" | "medium" | "medium/bright"
|
||||
variant: ShortcutKeyVariant
|
||||
): string | JSX.Element {
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3.5 h-5";
|
||||
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-3.5 h-5";
|
||||
|
||||
switch (modifier) {
|
||||
case "alt":
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { OutputColumnMetadata } from "@internal/tsql";
|
||||
import { Hash } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
BigNumberAggregationType,
|
||||
BigNumberConfiguration,
|
||||
} from "~/components/metrics/QueryWidget";
|
||||
import { AnimatedNumber } from "../AnimatedNumber";
|
||||
import { ChartBlankState } from "./ChartBlankState";
|
||||
import { Spinner } from "../Spinner";
|
||||
import { Paragraph } from "../Paragraph";
|
||||
|
||||
interface BigNumberCardProps {
|
||||
rows: Record<string, unknown>[];
|
||||
@@ -138,13 +139,7 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center [container-type:size]">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
No data to display
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
return <ChartBlankState icon={Hash} message="No data to display" />;
|
||||
}
|
||||
|
||||
const { displayValue, unitSuffix, decimalPlaces } = abbreviate
|
||||
|
||||
@@ -15,17 +15,11 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
|
||||
);
|
||||
};
|
||||
|
||||
const CardHeader = ({
|
||||
children,
|
||||
draggable,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
draggable?: boolean;
|
||||
}) => {
|
||||
const CardHeader = ({ children, draggable }: { children: ReactNode; draggable?: boolean }) => {
|
||||
return (
|
||||
<Header3
|
||||
className={cn(
|
||||
"drag-handle mb-3 flex items-center justify-between gap-2 px-3",
|
||||
"drag-handle mb-3 flex items-center justify-between gap-2 pl-4 pr-3",
|
||||
draggable && "cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -11,20 +11,13 @@ import {
|
||||
type XAxisProps,
|
||||
type YAxisProps,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
type ChartState,
|
||||
} from "~/components/primitives/charts/Chart";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ChartBarLoading, ChartBarInvalid, ChartBarNoData } from "./ChartLoading";
|
||||
import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart";
|
||||
import { useChartContext } from "./ChartContext";
|
||||
import { ChartRoot, useHasNoData } from "./ChartRoot";
|
||||
import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading";
|
||||
import { useHasNoData } from "./ChartRoot";
|
||||
// Legend is now rendered by ChartRoot outside the chart container
|
||||
import { ZoomTooltip, useZoomHandlers } from "./ChartZoom";
|
||||
import { getBarOpacity } from "./hooks/useHighlightState";
|
||||
import type { ZoomRange } from "./hooks/useZoomSelection";
|
||||
|
||||
//TODO: fix the first and last bars in a stack not having rounded corners
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Paragraph } from "../Paragraph";
|
||||
|
||||
export function ChartBlankState({
|
||||
icon: Icon,
|
||||
message,
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
message: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex h-full w-full items-center justify-center", className)}>
|
||||
<div className="-mt-3 flex flex-col items-center gap-2">
|
||||
{Icon && <Icon className="size-12 text-charcoal-700" />}
|
||||
<Paragraph variant="small" className="text-text-dimmed/70">
|
||||
{message}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export function ChartLegendCompound({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col pt-4 text-sm", scrollable && "max-h-[50%] min-h-0", className)}
|
||||
className={cn("flex flex-col px-2 pb-2 pt-4 text-sm", scrollable && "max-h-[50%] min-h-0", className)}
|
||||
>
|
||||
{/* Total row */}
|
||||
<div
|
||||
|
||||
@@ -507,9 +507,7 @@ export function QueryEditor({
|
||||
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(",")
|
||||
: "";
|
||||
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);
|
||||
@@ -773,7 +771,7 @@ export function QueryEditor({
|
||||
</div>
|
||||
) : results?.rows && results?.columns ? (
|
||||
<div
|
||||
className={`grid h-full max-h-full overflow-hidden bg-charcoal-900 ${
|
||||
className={`grid h-full max-h-full overflow-hidden bg-background-bright ${
|
||||
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
|
||||
? "grid-rows-[auto_1fr]"
|
||||
: "grid-rows-[1fr]"
|
||||
@@ -784,8 +782,10 @@ export function QueryEditor({
|
||||
periodClipped={results.periodClipped}
|
||||
organizationSlug={organization.slug}
|
||||
/>
|
||||
<div className="overflow-hidden p-2">
|
||||
<div className="overflow-hidden">
|
||||
<QueryWidget
|
||||
className="border-0"
|
||||
showTableHeaderOnEmpty
|
||||
title={
|
||||
<QueryTitle
|
||||
isTitleLoading={isTitleLoading}
|
||||
@@ -829,7 +829,7 @@ export function QueryEditor({
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="graph"
|
||||
className={`m-0 grid h-full max-h-full min-h-0 overflow-hidden bg-charcoal-900 ${
|
||||
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)
|
||||
@@ -878,7 +878,7 @@ export function QueryEditor({
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="bignumber"
|
||||
className={`m-0 grid h-full max-h-full min-h-0 overflow-hidden bg-charcoal-900 ${
|
||||
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)
|
||||
@@ -1219,8 +1219,9 @@ function ResultsChart({
|
||||
<>
|
||||
<ResizablePanelGroup className="overflow-hidden">
|
||||
<ResizablePanel id="chart-results">
|
||||
<div className="h-full overflow-hidden bg-charcoal-900 p-2">
|
||||
<div className="h-full overflow-hidden bg-background-bright">
|
||||
<QueryWidget
|
||||
className="border-0"
|
||||
title={
|
||||
<QueryTitle
|
||||
isTitleLoading={isTitleLoading}
|
||||
@@ -1297,8 +1298,9 @@ function ResultsBigNumber({
|
||||
<>
|
||||
<ResizablePanelGroup className="overflow-hidden">
|
||||
<ResizablePanel id="bignumber-results">
|
||||
<div className="h-full overflow-hidden bg-charcoal-900 p-2">
|
||||
<div className="h-full overflow-hidden bg-background-bright">
|
||||
<QueryWidget
|
||||
className="border-0"
|
||||
title={
|
||||
<QueryTitle
|
||||
isTitleLoading={isTitleLoading}
|
||||
@@ -1367,7 +1369,7 @@ function BigNumberConfigPanel({
|
||||
<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-dimmed">
|
||||
<Paragraph variant="extra-small" className="text-text-bright">
|
||||
Column
|
||||
</Paragraph>
|
||||
<Select
|
||||
@@ -1389,7 +1391,7 @@ function BigNumberConfigPanel({
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
<Paragraph variant="extra-small" className="text-text-bright">
|
||||
Sort order
|
||||
</Paragraph>
|
||||
<Select
|
||||
@@ -1417,7 +1419,7 @@ function BigNumberConfigPanel({
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
<Paragraph variant="extra-small" className="text-text-bright">
|
||||
Aggregation
|
||||
</Paragraph>
|
||||
<Select
|
||||
@@ -1451,7 +1453,7 @@ function BigNumberConfigPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
<Paragraph variant="extra-small" className="text-text-bright">
|
||||
Prefix
|
||||
</Paragraph>
|
||||
<Input
|
||||
@@ -1462,7 +1464,7 @@ function BigNumberConfigPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
<Paragraph variant="extra-small" className="text-text-bright">
|
||||
Suffix
|
||||
</Paragraph>
|
||||
<Input
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ export function AITabContent({
|
||||
key: (prev?.key ?? 0) + 1,
|
||||
}));
|
||||
}}
|
||||
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500"
|
||||
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500 focus-custom focus-visible:!rounded-full"
|
||||
>
|
||||
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
|
||||
<Paragraph variant="small" className="text-left transition group-hover:text-text-bright">
|
||||
|
||||
Reference in New Issue
Block a user