Files
triggerdotdev--trigger.dev/apps/webapp/app/components/code/ChartConfigPanel.tsx
T
Katia Bulatova fbd6df33b4 feat(webapp): Themes + contrast settings update (#4206)
Adds System Preferences, Dark and Light themes, gated by the
`hasThemeSwitcher` feature flag (off by default — dark stays the default
theme for everyone).

Old theme is now "Classic"and set as default. 
"System preferences" theme has both Light and Dark modes and uses your
laptop settings to use a correct one.
It has less color accents (specifically less colored text), and they are
the same for both modes, only grayscale values change between them. And
Light/Dark themes can be used separately.

New Contrast setting is available for System Preferences, Dark and Light
themes - it changes the contrast for the whole app. All new visual
Settings live in Account.
2026-08-03 19:29:33 +02:00

624 lines
22 KiB
TypeScript

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";
import {
type AggregationType,
type ChartConfiguration,
type SortDirection,
} from "../metrics/QueryWidget";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import SegmentedControl from "../primitives/SegmentedControl";
import { Select, SelectItem } from "../primitives/Select";
import { Switch } from "../primitives/Switch";
import { CHART_COLORS_BY_HUE, getSeriesColor } from "./chartColors";
export const defaultChartConfig: ChartConfiguration = {
chartType: "bar",
xAxisColumn: null,
yAxisColumns: [],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: {},
};
interface ChartConfigPanelProps {
columns: OutputColumnMetadata[];
config: ChartConfiguration;
onChange: (config: ChartConfiguration) => void;
className?: string;
}
// Type detection helpers
function isNumericType(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 isDateTimeType(type: string): boolean {
return (
type === "DateTime" ||
type === "DateTime64" ||
type === "Date" ||
type === "Date32" ||
type.startsWith("DateTime64(") ||
type.startsWith("Nullable(DateTime") ||
type.startsWith("Nullable(Date")
);
}
function isStringType(type: string): boolean {
return (
type === "String" ||
type === "LowCardinality(String)" ||
type === "Nullable(String)" ||
type.startsWith("Enum") ||
type.startsWith("FixedString")
);
}
export function ChartConfigPanel({ columns, config, onChange, className }: ChartConfigPanelProps) {
// Categorize columns by type
const { numericColumns, dateTimeColumns, categoricalColumns, allColumns } = useMemo(() => {
const numeric: OutputColumnMetadata[] = [];
const dateTime: OutputColumnMetadata[] = [];
const categorical: OutputColumnMetadata[] = [];
for (const col of columns) {
if (isNumericType(col.type)) {
numeric.push(col);
}
if (isDateTimeType(col.type)) {
dateTime.push(col);
}
if (isStringType(col.type) || isDateTimeType(col.type)) {
categorical.push(col);
}
}
return {
numericColumns: numeric,
dateTimeColumns: dateTime,
categoricalColumns: categorical,
allColumns: columns,
};
}, [columns]);
// Create a stable key from column names and types to detect actual changes
const columnsKey = useMemo(() => columns.map((c) => `${c.name}:${c.type}`).join(","), [columns]);
// Use refs to access current config/onChange without adding them as dependencies
const configRef = useRef(config);
const onChangeRef = useRef(onChange);
useEffect(() => {
configRef.current = config;
onChangeRef.current = onChange;
});
// Auto-select defaults when columns change
useEffect(() => {
if (columns.length === 0) return;
const currentConfig = configRef.current;
let needsUpdate = false;
const updates: Partial<ChartConfiguration> = {};
// Auto-select X-axis (prefer datetime, then first categorical)
if (!currentConfig.xAxisColumn) {
const defaultX = dateTimeColumns[0] ?? categoricalColumns[0] ?? columns[0];
if (defaultX) {
updates.xAxisColumn = defaultX.name;
needsUpdate = true;
}
}
// Auto-select Y-axis (first numeric column)
if (currentConfig.yAxisColumns.length === 0 && numericColumns.length > 0) {
updates.yAxisColumns = [numericColumns[0].name];
needsUpdate = true;
}
// Determine the effective x-axis column (either existing or newly selected)
const effectiveXAxis = updates.xAxisColumn ?? currentConfig.xAxisColumn;
// Auto-set sort to x-axis ASC if it's a datetime column and no sort is configured
if (
effectiveXAxis &&
!currentConfig.sortByColumn &&
dateTimeColumns.some((col) => col.name === effectiveXAxis)
) {
updates.sortByColumn = effectiveXAxis;
updates.sortDirection = "asc";
needsUpdate = true;
}
if (needsUpdate) {
onChangeRef.current({ ...currentConfig, ...updates });
}
// Only re-run when the actual column structure changes, not on every config change.
// columnsKey (a string) is stable when columns match, so this won't re-fire
// unnecessarily when the same query is re-run with identical columns.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnsKey]);
const updateConfig = useCallback(
(updates: Partial<ChartConfiguration>) => {
onChange({ ...config, ...updates });
},
[config, onChange]
);
// X-axis options: prefer datetime and string columns at the top
const xAxisOptions = useMemo(() => {
const preferred = [
...dateTimeColumns,
...categoricalColumns.filter((c) => !isDateTimeType(c.type)),
];
const preferredNames = new Set(preferred.map((c) => c.name));
const other = allColumns.filter((c) => !preferredNames.has(c.name));
const options: Array<{ value: string; label: string; type: string }> = [];
for (const col of preferred) {
options.push({ value: col.name, label: col.name, type: col.type });
}
for (const col of other) {
options.push({ value: col.name, label: col.name, type: col.type });
}
return options;
}, [allColumns, dateTimeColumns, categoricalColumns]);
// Y-axis options: numeric columns only
const yAxisOptions = useMemo(() => {
return numericColumns.map((col) => ({
value: col.name,
label: col.name,
type: col.type,
}));
}, [numericColumns]);
// Aggregation options
const aggregationOptions = [
{ value: "sum", label: "Sum" },
{ value: "avg", label: "Average" },
{ value: "count", label: "Count" },
{ value: "min", label: "Min" },
{ value: "max", label: "Max" },
];
// Group by options: categorical columns (excluding selected X axis)
const groupByOptions = useMemo(() => {
const options = categoricalColumns
.filter((col) => col.name !== config.xAxisColumn)
.map((col) => ({
value: col.name,
label: col.name,
type: col.type,
}));
return [{ value: "__none__", label: "None", type: "" }, ...options];
}, [categoricalColumns, config.xAxisColumn]);
// Sort by options: all columns
const sortByOptions = useMemo(() => {
const options = allColumns.map((col) => ({
value: col.name,
label: col.name,
type: col.type,
}));
return [{ value: "__none__", label: "None", type: "" }, ...options];
}, [allColumns]);
if (columns.length === 0) {
return (
<div className={cn("flex items-center justify-center p-4", className)}>
<Paragraph variant="small" className="text-text-dimmed">
Run a query to configure the chart
</Paragraph>
</div>
);
}
return (
<div className={cn("flex flex-col gap-3 p-2", className)}>
{/* Chart Type */}
<div className="flex flex-col gap-3">
<ConfigField label="Type">
<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-3">
{/* X-Axis */}
<ConfigField label="X-Axis">
<Select
value={config.xAxisColumn ?? ""}
setValue={(value) => {
const updates: Partial<ChartConfiguration> = { xAxisColumn: value || null };
// Auto-set sort to x-axis ASC if selecting a datetime column
if (value) {
const selectedCol = columns.find((c) => c.name === value);
if (selectedCol && isDateTimeType(selectedCol.type)) {
updates.sortByColumn = value;
updates.sortDirection = "asc";
}
}
updateConfig(updates);
}}
variant="tertiary/small"
placeholder="Select column"
items={xAxisOptions}
dropdownIcon
className="min-w-[140px]"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Y-Axis / Series */}
<ConfigField label={config.yAxisColumns.length > 1 ? "Series" : "Y-Axis"}>
{yAxisOptions.length === 0 ? (
<span className="text-xs text-text-dimmed">No numeric columns</span>
) : (
<div className="flex flex-col gap-1.5">
{/* Always show at least one dropdown, even if yAxisColumns is empty */}
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map((col, index) => (
<div key={index} className="flex items-center gap-1">
{col && !config.groupByColumn && (
<SeriesColorPicker
color={config.seriesColors?.[col] ?? getSeriesColor(index)}
onColorChange={(color) => {
updateConfig({
seriesColors: { ...config.seriesColors, [col]: color },
});
}}
/>
)}
<Select
value={col}
setValue={(value) => {
const newColumns = [...config.yAxisColumns];
const updates: Partial<ChartConfiguration> = {};
if (value) {
// If this is a new slot (empty string), add it
if (index >= config.yAxisColumns.length) {
newColumns.push(value);
} else {
// If the column name changed, migrate the color
const oldCol = newColumns[index];
if (oldCol && oldCol !== value && config.seriesColors?.[oldCol]) {
const newSeriesColors = { ...config.seriesColors };
newSeriesColors[value] = newSeriesColors[oldCol];
delete newSeriesColors[oldCol];
updates.seriesColors = newSeriesColors;
}
newColumns[index] = value;
}
} else if (index < config.yAxisColumns.length) {
newColumns.splice(index, 1);
}
updateConfig({ ...updates, yAxisColumns: newColumns });
}}
variant="tertiary/small"
placeholder="Select column"
items={yAxisOptions.filter(
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
)}
dropdownIcon
className="min-w-[140px] flex-1"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
}
</Select>
{index > 0 && (
<button
type="button"
onClick={() => {
const removedCol = config.yAxisColumns[index];
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
const updates: Partial<ChartConfiguration> = { yAxisColumns: newColumns };
// Clean up the color entry for the removed series
if (removedCol && config.seriesColors?.[removedCol]) {
const newSeriesColors = { ...config.seriesColors };
delete newSeriesColors[removedCol];
updates.seriesColors = newSeriesColors;
}
updateConfig(updates);
}}
className="rounded p-1 text-text-dimmed hover:bg-background-raised hover:text-text-bright"
title="Remove series"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{/* Add another series button - only show when we have at least one series and not grouped */}
{config.yAxisColumns.length > 0 &&
config.yAxisColumns.length < yAxisOptions.length &&
!config.groupByColumn && (
<button
type="button"
onClick={() => {
const availableColumns = yAxisOptions.filter(
(opt) => !config.yAxisColumns.includes(opt.value)
);
if (availableColumns.length > 0) {
updateConfig({
yAxisColumns: [...config.yAxisColumns, availableColumns[0].value],
});
}
}}
className="flex items-center gap-1 self-start rounded px-1 py-0.5 text-xs text-text-dimmed hover:bg-background-raised hover:text-text-bright"
>
<Plus className="h-3 w-3" />
Add series
</button>
)}
{config.groupByColumn && config.yAxisColumns.length === 1 && (
<span className="text-xxs text-text-dimmed">
Remove group by to add multiple series
</span>
)}
</div>
)}
</ConfigField>
{/* Aggregation */}
<ConfigField label="Aggregation">
<Select
value={config.aggregation}
setValue={(value) => updateConfig({ aggregation: value as AggregationType })}
variant="tertiary/small"
items={aggregationOptions}
dropdownIcon
className="min-w-[100px]"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Group By - disabled when multiple series are selected */}
<ConfigField label="Group by">
{config.yAxisColumns.length > 1 ? (
<span className="text-xs text-text-dimmed">Not available with multiple series</span>
) : (
<Select
value={config.groupByColumn ?? "__none__"}
setValue={(value) =>
updateConfig({ groupByColumn: value === "__none__" ? null : value })
}
variant="tertiary/small"
placeholder="None"
items={groupByOptions}
dropdownIcon
className="min-w-[140px]"
text={(t) => (t === "__none__" ? "None" : t)}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
{item.type && <TypeBadge type={item.type} />}
</span>
</SelectItem>
))
}
</Select>
)}
</ConfigField>
{/* Stacked toggle (when grouped or multiple series) */}
{(config.groupByColumn || config.yAxisColumns.length > 1) && (
<ConfigField label={config.groupByColumn ? "Stack groups" : "Stack series"}>
<Switch
variant="medium"
checked={config.stacked}
onCheckedChange={(checked) => updateConfig({ stacked: checked })}
/>
</ConfigField>
)}
{/* Order By */}
<ConfigField label="Order by">
<Select
value={config.sortByColumn ?? "__none__"}
setValue={(value) =>
updateConfig({ sortByColumn: value === "__none__" ? null : value })
}
variant="tertiary/small"
placeholder="None"
items={sortByOptions}
dropdownIcon
className="min-w-[140px]"
text={(t) => (t === "__none__" ? "None" : t)}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
{item.type && <TypeBadge type={item.type} />}
</span>
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Sort Direction (only when sorting) */}
{config.sortByColumn && (
<ConfigField label="Sort 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>
)}
</div>
</div>
);
}
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
{label && <span className="text-xs text-text-bright">{label}</span>}
{children}
</div>
);
}
function SeriesColorPicker({
color,
onColorChange,
}: {
color: string;
onColorChange: (color: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-0.5 hover:bg-background-raised"
title="Change series color"
>
<span
className="block h-4 w-4 rounded-full border border-text-bright/30"
style={{ backgroundColor: color }}
/>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<div className="grid grid-cols-6 gap-1.5">
{CHART_COLORS_BY_HUE.map((c) => (
<button
key={c}
type="button"
onClick={() => {
onColorChange(c);
setOpen(false);
}}
className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-text-bright/30"
style={{ backgroundColor: c }}
title={c}
>
{c === color && <CheckIcon className="h-3.5 w-3.5 text-white drop-shadow-md" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
);
}
function TypeBadge({ type }: { type: string }) {
// Simplify type for display
let displayType = type;
if (type.startsWith("Nullable(")) {
displayType = type.slice(9, -1) + "?";
}
if (type.startsWith("LowCardinality(")) {
displayType = type.slice(15, -1);
}
// Shorten long type names
if (displayType.length > 12) {
displayType = displayType.slice(0, 10) + "…";
}
return (
<span className="rounded bg-background-hover px-1 py-0.5 font-mono text-xxs text-text-dimmed">
{displayType}
</span>
);
}