fbd6df33b4
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.
624 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|