chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,73 @@
import { type ReactNode } from "react";
import { cn } from "~/utils/cn";
import { formatNumber, formatNumberCompact } from "~/utils/numberFormatter";
import { Header3 } from "../primitives/Headers";
import { Spinner } from "../primitives/Spinner";
import { SimpleTooltip } from "../primitives/Tooltip";
import { AnimatedNumber } from "../primitives/AnimatedNumber";
interface BigNumberProps {
title: ReactNode;
animate?: boolean;
loading?: boolean;
value?: number;
valueClassName?: string;
defaultValue?: number;
accessory?: ReactNode;
suffix?: ReactNode;
suffixClassName?: string;
compactThreshold?: number;
}
export function BigNumber({
title,
value,
defaultValue,
valueClassName,
suffix,
suffixClassName,
accessory,
animate = false,
loading = false,
compactThreshold,
}: BigNumberProps) {
const v = value ?? defaultValue;
const shouldCompact =
typeof compactThreshold === "number" && v !== undefined && v >= compactThreshold;
return (
<div className="flex flex-col justify-between gap-4 rounded-sm border border-grid-dimmed bg-background-bright p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Header3 className="leading-6">{title}</Header3>
{accessory && <div className="shrink-0">{accessory}</div>}
</div>
<div
className={cn(
"text-[3.75rem] font-normal tabular-nums leading-none text-text-bright",
valueClassName
)}
>
{loading ? (
<Spinner className="size-6" />
) : v !== undefined ? (
<div className="flex flex-wrap items-baseline gap-2">
{shouldCompact ? (
<SimpleTooltip
button={animate ? <AnimatedNumber value={v} /> : formatNumberCompact(v)}
content={formatNumber(v)}
/>
) : animate ? (
<AnimatedNumber value={v} />
) : (
formatNumber(v)
)}
{suffix && <div className={cn("text-xs", suffixClassName)}>{suffix}</div>}
</div>
) : (
""
)}
</div>
</div>
);
}
@@ -0,0 +1,165 @@
import { CubeIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { type ReactNode, useMemo } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
import { tablerIcons } from "~/utils/tablerIcons";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
import { AnthropicLogoIcon } from "~/assets/icons/AnthropicLogoIcon";
const shortcut = { key: "m" };
export type ModelOption = {
model: string;
system: string;
};
interface ModelsFilterProps {
possibleModels: ModelOption[];
}
function modelIcon(system: string, model: string): ReactNode {
// For gateway/openrouter, derive provider from model prefix
let provider = system.split(".")[0];
if (provider === "gateway" || provider === "openrouter") {
if (model.includes("/")) {
provider = model.split("/")[0].replace(/-/g, "");
}
}
// Special case: Anthropic uses a custom SVG icon
if (provider === "anthropic") {
return <AnthropicLogoIcon className="size-4 shrink-0 text-text-dimmed" />;
}
const iconName = `tabler-brand-${provider}`;
if (tablerIcons.has(iconName)) {
return (
<svg className="size-4 shrink-0 stroke-[1.5] text-text-dimmed">
<use xlinkHref={`${tablerSpritePath}#${iconName}`} />
</svg>
);
}
return <CubeIcon className="size-4 shrink-0 text-text-dimmed" />;
}
export function ModelsFilter({ possibleModels }: ModelsFilterProps) {
const { values, replace: _replace, del } = useSearchParams();
const selectedModels = values("models");
if (selectedModels.length === 0 || selectedModels.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ModelsDropdown
trigger={
<SelectTrigger
icon={<CubeIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by model"
className="pl-1.5"
>
<span className="ml-1">Models</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleModels={possibleModels}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ModelsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Model"
icon={<CubeIcon className="size-4" />}
value={appliedSummary(selectedModels)}
onRemove={() => del(["models"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleModels={possibleModels}
/>
)}
</FilterMenuProvider>
);
}
function ModelsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleModels,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleModels: ModelOption[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ models: values });
};
const filtered = useMemo(() => {
return possibleModels.filter((m) => {
return m.model?.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleModels]);
return (
<SelectProvider value={values("models")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by model..." value={searchValue} />
<SelectList>
{filtered.map((m) => (
<SelectItem
key={m.model}
value={m.model}
className="text-text-bright"
icon={modelIcon(m.system, m.model)}
>
{m.model}
</SelectItem>
))}
{filtered.length === 0 && <SelectItem disabled>No models found</SelectItem>}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,143 @@
import { CommandLineIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { type ReactNode, useMemo } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "o" };
interface OperationsFilterProps {
possibleOperations: string[];
}
/** Pretty-print an operation ID like "ai.generateText.doGenerate" → "generateText" */
function formatOperation(op: string): string {
const parts = op.split(".");
// ai.generateText.doGenerate → generateText
// ai.streamText.doStream → streamText
if (parts.length >= 2 && parts[0] === "ai") {
return parts[1];
}
return op;
}
export function OperationsFilter({ possibleOperations }: OperationsFilterProps) {
const { values, replace: _replace, del } = useSearchParams();
const selectedOperations = values("operations");
if (selectedOperations.length === 0 || selectedOperations.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<OperationsDropdown
trigger={
<SelectTrigger
icon={<CommandLineIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by operation"
className="pl-1.5"
>
<span className="ml-1">Operations</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleOperations={possibleOperations}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<OperationsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Operation"
icon={<CommandLineIcon className="size-4" />}
value={appliedSummary(selectedOperations.map(formatOperation))}
onRemove={() => del(["operations"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleOperations={possibleOperations}
/>
)}
</FilterMenuProvider>
);
}
function OperationsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleOperations,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleOperations: string[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ operations: values });
};
const filtered = useMemo(() => {
const q = searchValue.toLowerCase();
return possibleOperations.filter(
(op) => op.toLowerCase().includes(q) || formatOperation(op).toLowerCase().includes(q)
);
}, [searchValue, possibleOperations]);
return (
<SelectProvider value={values("operations")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by operation..." value={searchValue} />
<SelectList>
{filtered.map((op) => (
<SelectItem
key={op}
value={op}
className="text-text-bright"
icon={<CommandLineIcon className="size-4 text-text-dimmed" />}
>
{formatOperation(op)}
</SelectItem>
))}
{filtered.length === 0 && <SelectItem disabled>No operations found</SelectItem>}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,131 @@
import { DocumentTextIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { type ReactNode, useMemo } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "p" };
interface PromptsFilterProps {
possiblePrompts: string[];
}
export function PromptsFilter({ possiblePrompts }: PromptsFilterProps) {
const { values, replace: _replace, del } = useSearchParams();
const selectedPrompts = values("prompts");
if (selectedPrompts.length === 0 || selectedPrompts.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<PromptsDropdown
trigger={
<SelectTrigger
icon={<DocumentTextIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by prompt"
className="pl-1.5"
>
<span className="ml-1">Prompts</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possiblePrompts={possiblePrompts}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<PromptsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Prompt"
icon={<DocumentTextIcon className="size-4" />}
value={appliedSummary(selectedPrompts)}
onRemove={() => del(["prompts"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possiblePrompts={possiblePrompts}
/>
)}
</FilterMenuProvider>
);
}
function PromptsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possiblePrompts,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possiblePrompts: string[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ prompts: values });
};
const filtered = useMemo(() => {
return possiblePrompts.filter((p) => {
return p.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possiblePrompts]);
return (
<SelectProvider value={values("prompts")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by prompt..." value={searchValue} />
<SelectList>
{filtered.map((slug) => (
<SelectItem
key={slug}
value={slug}
className="text-text-bright"
icon={<DocumentTextIcon className="size-4 text-text-dimmed" />}
>
{slug}
</SelectItem>
))}
{filtered.length === 0 && <SelectItem disabled>No prompts found</SelectItem>}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,129 @@
import { ServerIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { type ReactNode, useMemo } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "r" };
interface ProvidersFilterProps {
possibleProviders: string[];
}
export function ProvidersFilter({ possibleProviders }: ProvidersFilterProps) {
const { values, replace: _replace, del } = useSearchParams();
const selectedProviders = values("providers");
if (selectedProviders.length === 0 || selectedProviders.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ProvidersDropdown
trigger={
<SelectTrigger
icon={<ServerIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by provider"
className="pl-1.5"
>
<span className="ml-1">Providers</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleProviders={possibleProviders}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ProvidersDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Provider"
icon={<ServerIcon className="size-4" />}
value={appliedSummary(selectedProviders)}
onRemove={() => del(["providers"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleProviders={possibleProviders}
/>
)}
</FilterMenuProvider>
);
}
function ProvidersDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleProviders,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleProviders: string[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ providers: values });
};
const filtered = useMemo(() => {
return possibleProviders.filter((p) => p.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue, possibleProviders]);
return (
<SelectProvider value={values("providers")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by provider..." value={searchValue} />
<SelectList>
{filtered.map((provider) => (
<SelectItem
key={provider}
value={provider}
className="text-text-bright"
icon={<ServerIcon className="size-4 text-text-dimmed" />}
>
{provider}
</SelectItem>
))}
{filtered.length === 0 && <SelectItem disabled>No providers found</SelectItem>}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,528 @@
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<typeof ChartType>;
const SortDirection = z.union([z.literal("asc"), z.literal("desc")]);
export type SortDirection = z.infer<typeof SortDirection>;
const AggregationType = z.union([
z.literal("sum"),
z.literal("avg"),
z.literal("count"),
z.literal("min"),
z.literal("max"),
]);
export type AggregationType = z.infer<typeof AggregationType>;
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<typeof ChartConfiguration>;
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<typeof BigNumberAggregationType>;
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<typeof BigNumberConfiguration>;
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<typeof QueryWidgetConfig>;
/** Result data containing rows and column metadata */
export type QueryWidgetData = {
rows: Record<string, unknown>[];
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<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);
}, []);
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 (
<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>
<SimpleTooltip
button={
<span className="opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="minimal/small"
LeadingIcon={Maximize2}
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
onClick={() => setIsFullscreen(true)}
className="px-1!"
/>
</span>
}
content={
<span className="flex items-center gap-1">
Maximize
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
</span>
}
asChild
/>
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverVerticalEllipseTrigger
isOpen={isMenuOpen}
className={cn(
"transition-opacity",
isMenuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
/>
<PopoverContent align="end" className="p-0">
<div className="flex flex-col gap-1 p-1">
{hasEditActions && (
<>
{onEdit && (
<PopoverMenuItem
icon={IconChartHistogram}
title="Edit chart"
onClick={() => {
onEdit(props.data);
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
)}
{onRename && (
<PopoverMenuItem
icon={PencilSquareIcon}
title="Rename"
onClick={() => {
setRenameValue(titleString ?? "");
setIsRenameDialogOpen(true);
setIsMenuOpen(false);
}}
/>
)}
{onDuplicate && (
<PopoverMenuItem
icon={DocumentDuplicateIcon}
title="Duplicate chart"
onClick={() => {
onDuplicate(props.data);
setIsMenuOpen(false);
}}
className="pr-4"
/>
)}
</>
)}
{query && (
<PopoverMenuItem
icon={ClipboardIcon}
title="Copy query"
onClick={() => {
copyQuery();
setIsMenuOpen(false);
}}
/>
)}
<PopoverMenuItem
icon={IconBraces}
title="Copy JSON"
disabled={!hasData}
onClick={() => {
copyJSON();
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
<PopoverMenuItem
icon={IconFileTypeCsv}
title="Copy CSV"
disabled={!hasData}
onClick={() => {
copyCSV();
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
{onDelete && (
<PopoverMenuItem
icon={TrashIcon}
title="Delete chart"
leadingIconClassName="text-error"
className="text-error hover:bg-error/10!"
onClick={() => {
onDelete();
setIsMenuOpen(false);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
{accessory}
</Card.Accessory>
</Card.Header>
<LoadingBarDivider isLoading={isLoading ?? false} className="bg-transparent" />
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
{isResizing ? (
<div className="flex h-full flex-1 items-center justify-center p-3">
<div className="flex flex-col items-center gap-1 text-text-dimmed">
<ChartBarIcon className="size-10 text-text-dimmed" />{" "}
<span className="text-base font-medium">Resizing...</span>
</div>
</div>
) : error ? (
<div className="p-3">
<Callout variant="error">{error}</Callout>
</div>
) : (
<QueryWidgetBody
{...props}
title={title}
isFullscreen={isFullscreen}
setIsFullscreen={setIsFullscreen}
isLoading={isLoading ?? false}
/>
)}
</Card.Content>
</Card>
{/* Rename Dialog */}
{onRename && (
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<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());
setIsRenameDialogOpen(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Chart title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
);
}
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 (
<>
<TSQLResultsTable
rows={data.rows}
columns={data.columns}
prettyFormatting={config.prettyFormatting}
sorting={config.sorting}
showHeaderOnEmpty={showTableHeaderOnEmpty}
hiddenColumns={hiddenColumns}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent
fullscreen
className="flex flex-col gap-0 bg-background-bright px-0 pb-0"
>
<DialogHeader className="px-4">{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 pt-2.5">
<TSQLResultsTable
rows={data.rows}
columns={data.columns}
prettyFormatting={config.prettyFormatting}
sorting={config.sorting}
showHeaderOnEmpty={showTableHeaderOnEmpty}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "chart": {
return (
<>
<QueryResultsChart
rows={data.rows}
columns={data.columns}
config={config}
timeRange={timeRange}
onViewAllLegendItems={() => setIsFullscreen(true)}
isLoading={showLoading}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 overflow-hidden pt-4">
<QueryResultsChart
rows={data.rows}
columns={data.columns}
config={config}
timeRange={timeRange}
fullLegend
legendScrollable
isLoading={showLoading}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "bignumber": {
return (
<>
<BigNumberCard
rows={data.rows}
columns={data.columns}
config={config}
isLoading={showLoading}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="flex min-h-0 w-full flex-1 items-center justify-center pt-4">
<BigNumberCard
rows={data.rows}
columns={data.columns}
config={config}
isLoading={showLoading}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "title": {
// Title widgets are rendered by TitleWidget, not QueryWidget
return null;
}
default: {
assertNever(type);
}
}
}
@@ -0,0 +1,214 @@
import * as Ariakit from "@ariakit/react";
import { RectangleStackIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { matchSorter } from "match-sorter";
import { type ReactNode, useMemo } from "react";
import { TasksIcon } from "~/assets/icons/TasksIcon";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { useDebounceEffect } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "q" };
export function QueuesFilter() {
const { values, replace: _replace, del } = useSearchParams();
const selectedQueues = values("queues");
if (selectedQueues.length === 0 || selectedQueues.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<QueuesDropdown
trigger={
<SelectTrigger
icon={<RectangleStackIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by queue"
className="pl-1.5"
>
<span className="ml-1">Queues</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<QueuesDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Queues"
icon={<RectangleStackIcon className="size-4" />}
value={appliedSummary(selectedQueues.map((v) => v.replace("task/", "")))}
onRemove={() => del(["queues"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function QueuesDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({
queues: values.length > 0 ? values : undefined,
});
};
const queueValues = values("queues").filter((v) => v !== "");
const selected = queueValues.length > 0 ? queueValues : undefined;
const fetcher = useFetcher<typeof queuesLoader>();
useDebounceEffect(
searchValue,
(s) => {
const searchParams = new URLSearchParams();
searchParams.set("per_page", "25");
if (searchValue) {
searchParams.set("query", s);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
environment.slug
}/queues?${searchParams.toString()}`
);
},
250
);
const filtered = useMemo(() => {
// Use a Map to deduplicate by value
const itemsMap = new Map<string, { name: string; type: "custom" | "task"; value: string }>();
// Add selected items first (for items not yet loaded from fetcher)
for (const queueName of selected ?? []) {
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
if (!queueItem) {
if (queueName.startsWith("task/")) {
itemsMap.set(queueName, {
name: queueName.replace("task/", ""),
type: "task",
value: queueName,
});
} else {
itemsMap.set(queueName, {
name: queueName,
type: "custom",
value: queueName,
});
}
}
}
// Add items from fetcher data
if (fetcher.data !== undefined) {
for (const q of fetcher.data.queues) {
const value = q.type === "task" ? `task/${q.name}` : q.name;
itemsMap.set(value, {
name: q.name,
type: q.type,
value,
});
}
}
const items = Array.from(itemsMap.values());
return matchSorter(items, searchValue, {
keys: ["name"],
});
}, [searchValue, fetcher.data, selected]);
return (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by queues..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
<SelectList>
{filtered.length > 0
? filtered.map((queue) => (
<SelectItem
key={queue.value}
value={queue.value}
className="text-text-bright"
icon={
queue.type === "task" ? (
<TasksIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
{queue.name}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No queues found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,190 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { useFetcher, useNavigate } from "@remix-run/react";
import { IconCheck } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { useEnvironment } from "~/hooks/useEnvironment";
import {
useCustomDashboards,
useOrganization,
useWidgetLimitPerDashboard,
} from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
import { Button } from "../primitives/Buttons";
import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog";
import { FormButtons } from "../primitives/FormButtons";
import { Paragraph } from "../primitives/Paragraph";
import type { QueryWidgetConfig } from "./QueryWidget";
export type SaveToDashboardDialogProps = {
title: string;
query: string;
config: QueryWidgetConfig;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
};
export function SaveToDashboardDialog({
title,
query,
config,
isOpen,
onOpenChange,
}: SaveToDashboardDialogProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const customDashboards = useCustomDashboards();
const widgetLimit = useWidgetLimitPerDashboard();
const fetcher = useFetcher<{ success: boolean }>();
const navigate = useNavigate();
// Find the first dashboard that isn't at the widget limit
const firstAvailableDashboard = customDashboards.find((d) => d.widgetCount < widgetLimit);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
firstAvailableDashboard?.friendlyId ?? customDashboards[0]?.friendlyId ?? null
);
// Build the form action URL
const formAction = selectedDashboardId
? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${selectedDashboardId}/widgets`
: "";
const isLoading = fetcher.state === "submitting";
// Check if selected dashboard is at widget limit
const selectedDashboard = customDashboards.find((d) => d.friendlyId === selectedDashboardId);
const isSelectedAtLimit = selectedDashboard
? selectedDashboard.widgetCount >= widgetLimit
: false;
// Navigate to the dashboard when the fetcher completes successfully
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.success && selectedDashboardId) {
onOpenChange(false);
navigate(
v3CustomDashboardPath(
{ slug: organization.slug },
{ slug: project.slug },
{ slug: environment.slug },
{ friendlyId: selectedDashboardId }
)
);
}
}, [
fetcher.state,
fetcher.data,
selectedDashboardId,
onOpenChange,
navigate,
organization.slug,
project.slug,
environment.slug,
]);
// Update selection if dashboards change
useEffect(() => {
if (customDashboards.length > 0 && !selectedDashboardId) {
const available = customDashboards.find((d) => d.widgetCount < widgetLimit);
setSelectedDashboardId(available?.friendlyId ?? customDashboards[0].friendlyId);
}
}, [customDashboards, selectedDashboardId, widgetLimit]);
if (customDashboards.length === 0) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Add to dashboard</DialogHeader>
<div className="mt-1! space-y-4">
<Paragraph variant="small" className="text-text-dimmed">
You don't have any custom dashboards yet. Create one first from the sidebar menu.
</Paragraph>
<FormButtons
className="justify-end"
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Close</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Add to dashboard</DialogHeader>
<fetcher.Form method="post" action={formAction} className="space-y-4">
<input type="hidden" name="action" value="add" />
<input type="hidden" name="title" value={title} />
<input type="hidden" name="query" value={query} />
<input type="hidden" name="config" value={JSON.stringify(config)} />
<div className="mt-1! space-y-2">
<Paragraph variant="small" className="text-text-dimmed">
Select a dashboard to add this chart to:
</Paragraph>
<div className="max-h-64 space-y-1 overflow-y-auto">
{customDashboards.map((dashboard) => {
const isAtLimit = dashboard.widgetCount >= widgetLimit;
return (
<button
key={dashboard.friendlyId}
type="button"
onClick={() => !isAtLimit && setSelectedDashboardId(dashboard.friendlyId)}
disabled={isAtLimit}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition",
isAtLimit
? "cursor-not-allowed opacity-50"
: selectedDashboardId === dashboard.friendlyId
? "bg-background-raised text-text-bright"
: "text-text-dimmed hover:bg-background-hover hover:text-text-bright"
)}
>
{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(
"shrink-0 text-xs",
isAtLimit ? "text-error" : "text-text-dimmed"
)}
>
{dashboard.widgetCount}/{widgetLimit}
</span>
</button>
);
})}
</div>
</div>
<FormButtons
confirmButton={
<Button
type="submit"
variant="primary/medium"
disabled={isLoading || !selectedDashboardId || isSelectedAtLimit}
>
{isLoading ? "Saving..." : "Save"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
}
/>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,139 @@
import * as Ariakit from "@ariakit/react";
import { FolderIcon } from "@heroicons/react/20/solid";
import { useRef } from "react";
import { EnvironmentIcon, EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Avatar } from "~/components/primitives/Avatar";
import { SelectItem, SelectPopover, SelectProvider } from "~/components/primitives/Select";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import type { QueryScope } from "~/services/queryService.server";
const scopeOptions = [
{ value: "environment", label: "Environment" },
{ value: "project", label: "Project" },
{ value: "organization", label: "Organization" },
] as const;
export type ScopeFilterProps = {
shortcut?: ShortcutDefinition;
/** Controlled value. If provided, the filter uses controlled mode and ignores search params. */
value?: QueryScope;
/** Called when the user selects a new scope. Required when `value` is provided. */
onValueChange?: (scope: QueryScope) => void;
};
export function ScopeFilter({ shortcut, value, onValueChange }: ScopeFilterProps = {}) {
const { value: paramValue, replace } = useSearchParams();
const isControlled = value !== undefined;
const scope: QueryScope = isControlled
? value
: ((paramValue("scope") as QueryScope) ?? "environment");
const triggerRef = useRef<HTMLButtonElement>(null);
const handleChange = (newScope: string) => {
if (isControlled) {
onValueChange?.(newScope as QueryScope);
return;
}
replace({ scope: newScope === "environment" ? undefined : newScope });
};
useShortcutKeys({
shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
disabled: !shortcut,
});
return (
<SelectProvider value={scope} setValue={handleChange}>
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
<AppliedFilter
label="Scope"
value={<ScopeItem scope={scope} />}
removable={false}
variant="secondary/small"
/>
</Ariakit.TooltipAnchor>
{shortcut && (
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright py-1.5 pl-2.5 pr-2 text-xs text-text-dimmed">
<div className="flex items-center gap-1.5">
<span>Change scope</span>
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant="small" />
</div>
</Ariakit.Tooltip>
)}
</Ariakit.TooltipProvider>
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
{scopeOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="gap-x-2 text-text-bright"
icon={<ScopeIcon scope={option.value} />}
>
<ScopeLabel scope={option.value} />
</SelectItem>
))}
</SelectPopover>
</SelectProvider>
);
}
function ScopeIcon({ scope }: { scope: QueryScope }) {
const organization = useOrganization();
const environment = useEnvironment();
switch (scope) {
case "organization":
return <Avatar avatar={organization.avatar} size={1} orgName={organization.title} />;
case "project":
return <FolderIcon className="size-4 text-indigo-500" />;
case "environment":
return <EnvironmentIcon environment={environment} className="size-4" />;
default:
return null;
}
}
function ScopeLabel({ scope }: { scope: QueryScope }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
switch (scope) {
case "organization":
return <span className="text-text-bright">{organization.title}</span>;
case "project":
return <span className="text-text-bright">{project.name}</span>;
case "environment":
return <EnvironmentLabel environment={environment} disableTooltip />;
default:
return scope;
}
}
function ScopeItem({ scope }: { scope: QueryScope }) {
return (
<span className="flex items-center gap-1">
<ScopeIcon scope={scope} />
<ScopeLabel scope={scope} />
</span>
);
}
@@ -0,0 +1,125 @@
import { useState } from "react";
import { PencilIcon, TrashIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverVerticalEllipseTrigger,
} from "../primitives/Popover";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
export type TitleWidgetProps = {
title: string;
isDraggable?: boolean;
isResizing?: boolean;
/** Callback when rename is clicked. Receives the new title. */
onRename?: (newTitle: string) => void;
/** Callback when delete is clicked. */
onDelete?: () => void;
};
export function TitleWidget({
title,
isDraggable,
isResizing,
onRename,
onDelete,
}: TitleWidgetProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(title);
const hasMenu = onRename || onDelete;
return (
<div className="h-full">
<div
className={cn(
"group flex h-full items-center gap-2 rounded-lg border border-grid-bright bg-background-bright px-4",
isDraggable && "drag-handle cursor-grab active:cursor-grabbing"
)}
>
<span className="min-w-0 flex-1 truncate text-lg font-medium text-text-bright">
{title}
</span>
{hasMenu && (
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverVerticalEllipseTrigger isOpen={isMenuOpen} />
<PopoverContent align="end" className="p-0">
<div className="flex flex-col gap-1 p-1">
{onRename && (
<PopoverMenuItem
icon={PencilIcon}
title="Rename"
onClick={() => {
setRenameValue(title);
setIsRenameDialogOpen(true);
setIsMenuOpen(false);
}}
/>
)}
{onDelete && (
<PopoverMenuItem
icon={TrashIcon}
title="Delete"
leadingIconClassName="text-error"
className="text-error hover:bg-error/10!"
onClick={() => {
onDelete();
setIsMenuOpen(false);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
{/* Rename Dialog */}
{onRename && (
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Rename title</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (renameValue.trim()) {
onRename(renameValue.trim());
setIsRenameDialogOpen(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Section title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
);
}