Revert "Wire analytics tables to filter-first search and remove AI query builder."

This reverts commit 8fd8479915.
This commit is contained in:
Developing-Gamer 2026-07-14 11:14:27 -07:00
parent 8fd8479915
commit e133a6000f
4 changed files with 690 additions and 87 deletions

View File

@ -0,0 +1,134 @@
"use client";
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
import { cn } from "@/lib/utils";
import {
EyeIcon,
PaperPlaneTiltIcon,
SparkleIcon,
SpinnerGapIcon,
XIcon,
} from "@phosphor-icons/react";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { useCallback, useState, type KeyboardEvent } from "react";
import type { AiQueryChat } from "./use-ai-query-chat";
type AiQueryBarProps = {
chat: AiQueryChat,
/** Whether the AI has committed a query (drives the purple accent). */
isActive: boolean,
/** Invoked when the user clicks the eye button. */
onOpenDialog: () => void,
/** Invoked when the user clicks the reset (clear) button. */
onReset: () => void,
};
/**
* AI-powered search bar for the analytics tables page. Drops into the
* DataGridToolbar in place of the built-in quick-search input. Typing
* a message and pressing Enter sends a prompt to the shared AI chat;
* the AI responds by calling the `queryAnalytics` tool, and the
* extracted query drives the grid (via the parent component).
*/
export function AiQueryBar({
chat,
isActive,
onOpenDialog,
onReset,
}: AiQueryBarProps) {
const [input, setInput] = useState("");
const handleSubmit = useCallback(() => {
const text = input.trim();
if (!text || chat.isResponding) return;
setInput("");
runAsynchronously(chat.sendMessage({ text }));
}, [input, chat]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
return (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<div
className={cn(
"group flex h-8 min-w-0 flex-1 items-center gap-2 rounded-xl px-2.5 sm:max-w-72",
"border border-black/[0.08] dark:border-white/[0.06]",
"bg-white dark:bg-background shadow-sm ring-1 ring-black/[0.08] dark:ring-white/[0.06] transition-shadow hover:transition-none",
isActive
? "ring-purple-500/30 focus-within:ring-purple-500/50 dark:ring-purple-400/30 dark:focus-within:ring-purple-400/50"
: "hover:ring-black/[0.12] dark:hover:ring-white/[0.1] focus-within:ring-foreground/[0.18]",
)}
>
<SparkleIcon
className={cn(
"h-3.5 w-3.5 shrink-0",
isActive ? "text-purple-400" : "text-muted-foreground/50",
)}
/>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isActive ? "Refine with AI…" : "Ask about your analytics…"
}
className={cn(
"min-w-0 flex-1 bg-transparent text-xs outline-none",
"placeholder:text-muted-foreground/40",
)}
disabled={chat.isResponding}
/>
{chat.isResponding && (
<SpinnerGapIcon className="h-3 w-3 shrink-0 animate-spin text-purple-400" />
)}
{!chat.isResponding && input.trim().length > 0 && (
<button
type="button"
onClick={handleSubmit}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-purple-400 transition-colors hover:transition-none"
aria-label="Send"
>
<PaperPlaneTiltIcon className="h-3 w-3" />
</button>
)}
<SimpleTooltip tooltip="Open AI query builder">
<button
type="button"
onClick={onOpenDialog}
className={cn(
"shrink-0 rounded p-0.5 transition-colors hover:transition-none",
isActive
? "text-purple-400 hover:text-purple-300"
: "text-muted-foreground/60 hover:text-foreground",
)}
aria-label="Open AI query builder"
>
<EyeIcon className="h-3 w-3" />
</button>
</SimpleTooltip>
</div>
{isActive && (
<SimpleTooltip tooltip="Clear AI query">
<button
type="button"
onClick={onReset}
className="shrink-0 rounded p-1 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
aria-label="Clear AI query"
>
<XIcon className="h-3 w-3" />
</button>
</SimpleTooltip>
)}
</div>
);
}

View File

@ -0,0 +1,483 @@
"use client";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { Thread } from "@/components/assistant-ui/thread";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import type { CmdKPreviewProps } from "@/components/cmdk-commands";
import { Button } from "@/components/ui";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
import { Textarea } from "@/components/ui/textarea";
import { CreateDashboardPreview } from "@/components/commands/create-dashboard/create-dashboard-preview";
import { useUpdateConfig } from "@/components/config-update";
import { AssistantRuntimeProvider, type ToolCallContentPartProps } from "@assistant-ui/react";
import {
ArrowClockwiseIcon,
CheckIcon,
CopyIcon,
FloppyDiskIcon,
LayoutIcon,
SparkleIcon,
TrashIcon,
XIcon,
} from "@phosphor-icons/react";
import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto";
import {
runAsynchronously,
runAsynchronouslyWithAlert,
} from "@hexclave/shared/dist/utils/promises";
import {
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useAdminApp } from "../../use-admin-app";
import type { AiQueryChat } from "./use-ai-query-chat";
function AnalyticsQueryToolCall(
props: ToolCallContentPartProps & {
currentQuery: string | null,
onApplyQuery: (query: string) => void,
},
) {
const query = (props.args as { query?: unknown } | undefined)?.query;
const queryString = typeof query === "string" ? query : "";
const result = props.result as { success?: unknown } | null | undefined;
const isSuccessful = props.status.type === "complete" && result?.success !== false;
const canApply = isSuccessful && queryString.trim().length > 0 && queryString !== props.currentQuery;
return (
<ToolFallback
{...props}
headerAction={canApply ? (
<span className="flex items-center" onClick={(e) => e.stopPropagation()}>
<SimpleTooltip tooltip="Use this query">
<button
type="button"
onClick={() => props.onApplyQuery(queryString)}
className="inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-[10px] font-medium leading-none text-muted-foreground/70 transition-colors hover:transition-none hover:bg-foreground/[0.06] hover:text-foreground"
aria-label="Use this query"
>
<ArrowClockwiseIcon className="h-2.5 w-2.5" />
Use query
</button>
</SimpleTooltip>
</span>
) : undefined}
/>
);
}
function AiQueryWelcome() {
return (
<div className="flex w-full max-w-[var(--thread-max-width)] flex-grow flex-col">
<div className="flex w-full flex-grow flex-col items-center justify-center py-16 px-6">
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center mb-4 ring-1 ring-purple-500/20">
<SparkleIcon className="w-6 h-6 text-purple-400" weight="duotone" />
</div>
<h2 className="text-base font-semibold tracking-tight text-foreground mb-1.5">
Build an analytics query
</h2>
<p className="text-xs text-muted-foreground text-center max-w-[300px] leading-relaxed">
Ask for a table, segment, trend, or funnel. Try &ldquo;daily signups over the last 30 days&rdquo; or &ldquo;top 10 users by event count this week&rdquo;.
</p>
</div>
</div>
);
}
// ─── Save query sub-dialog ──────────────────────────────────────────
function SaveQueryInlineDialog({
open,
onOpenChange,
sqlQuery,
}: {
open: boolean,
onOpenChange: (open: boolean) => void,
sqlQuery: string,
}) {
const adminApp = useAdminApp();
const project = adminApp.useProject();
const config = project.useConfig();
const updateConfig = useUpdateConfig();
const [displayName, setDisplayName] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) {
setDisplayName("");
setSaving(false);
}
}, [open]);
const handleSave = useCallback(async () => {
if (!displayName.trim() || !sqlQuery.trim()) return;
setSaving(true);
try {
// Reuse an existing folder if available, otherwise create an
// "AI Queries" bucket on the fly so the save flow never stalls
// on folder management.
const existingFolders = Object.entries(config.analytics.queryFolders);
let folderId: string;
if (existingFolders.length > 0) {
folderId = existingFolders[0]![0];
} else {
folderId = generateSecureRandomString();
await updateConfig({
adminApp,
configUpdate: {
[`analytics.queryFolders.${folderId}`]: {
displayName: "AI Queries",
sortOrder: 0,
queries: {},
},
},
pushable: false,
});
}
const queryId = generateSecureRandomString();
await updateConfig({
adminApp,
configUpdate: {
[`analytics.queryFolders.${folderId}.queries.${queryId}`]: {
displayName: displayName.trim(),
sqlQuery,
},
},
pushable: false,
});
onOpenChange(false);
} finally {
setSaving(false);
}
}, [displayName, sqlQuery, config, updateConfig, adminApp, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save query</DialogTitle>
</DialogHeader>
<DialogBody>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="ai-save-query-name">Name</Label>
<Input
id="ai-save-query-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Recent signups"
onKeyDown={(e) => {
if (e.key === "Enter") {
runAsynchronouslyWithAlert(handleSave);
}
}}
/>
</div>
<div className="rounded-md border border-border/50 bg-muted/30 p-2">
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground max-h-32 overflow-auto">
{sqlQuery}
</pre>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => runAsynchronouslyWithAlert(handleSave)}
disabled={!displayName.trim() || saving}
>
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── Build dashboard sub-dialog ─────────────────────────────────────
/**
* Reuses the existing `CreateDashboardPreview` component (the same
* one the Cmd+K command center uses) so the dashboard-builder
* experience is identical whether you enter it from the command
* palette or from the analytics AI query builder. Most of
* `CmdKPreviewProps` are unused by `CreateDashboardPreview` internally,
* so we pass no-op stubs for them.
*/
function BuildDashboardDialog({
open,
onOpenChange,
sqlQuery,
}: {
open: boolean,
onOpenChange: (open: boolean) => void,
sqlQuery: string,
}) {
// Synthesize a prompt that pre-seeds the SQL query as context so
// the dashboard the AI generates visualizes exactly these results.
const dashboardPrompt = useMemo(
() =>
`Build a dashboard that visualizes the results of this ClickHouse query:\n\n\`\`\`sql\n${sqlQuery}\n\`\`\``,
[sqlQuery],
);
const stubProps: Omit<CmdKPreviewProps, "query" | "onClose"> = {
isSelected: true,
registerOnFocus: () => {
// no-op
},
unregisterOnFocus: () => {
// no-op
},
onBlur: () => {
// no-op
},
registerNestedCommands: () => {
// no-op
},
navigateToNested: () => {
// no-op
},
depth: 0,
pathname: "",
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl h-[80vh] p-0 overflow-hidden flex flex-col">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="flex items-center gap-2 text-sm">
<LayoutIcon className="h-4 w-4 text-cyan-500" />
Build dashboard
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0">
{open && (
<CreateDashboardPreview
query={dashboardPrompt}
onClose={() => onOpenChange(false)}
{...stubProps}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── Main dialog ────────────────────────────────────────────────────
type AiQueryDialogProps = {
open: boolean,
onOpenChange: (open: boolean) => void,
chat: AiQueryChat,
/** The query currently driving the data grid (may be `null` if none yet). */
currentQuery: string | null,
};
export function AiQueryDialog({
open,
onOpenChange,
chat,
currentQuery,
}: AiQueryDialogProps) {
const [copied, setCopied] = useState(false);
const [saveOpen, setSaveOpen] = useState(false);
const [buildOpen, setBuildOpen] = useState(false);
const [currentQueryDraft, setCurrentQueryDraft] = useState(currentQuery ?? "");
const assistantContentComponents = useMemo(() => ({
Text: MarkdownText,
tools: {
Fallback: ToolFallback,
by_name: {
queryAnalytics: (props: ToolCallContentPartProps) => (
<AnalyticsQueryToolCall
{...props}
currentQuery={currentQuery}
onApplyQuery={chat.rewindToQuery}
/>
),
},
},
}), [chat.rewindToQuery, currentQuery]);
useEffect(() => {
setCurrentQueryDraft(currentQuery ?? "");
}, [currentQuery]);
const applyCurrentQueryDraft = useCallback(() => {
if (currentQueryDraft.trim().length === 0 || currentQueryDraft === currentQuery) return;
chat.rewindToQuery(currentQueryDraft);
}, [chat, currentQuery, currentQueryDraft]);
const handleCopy = useCallback(async () => {
if (!currentQueryDraft) return;
await navigator.clipboard.writeText(currentQueryDraft);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [currentQueryDraft]);
const canActOnQuery = currentQueryDraft.trim().length > 0;
const hasUnappliedCurrentQueryEdits = currentQueryDraft.trim().length > 0 && currentQueryDraft !== (currentQuery ?? "");
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent noCloseButton className="max-w-3xl h-[80vh] p-0 overflow-hidden flex flex-col gap-0">
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border/40">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-sm">
<SparkleIcon className="h-4 w-4 text-purple-400" />
AI query builder
</DialogTitle>
<div className="flex items-center gap-1">
{chat.messages.length > 0 && (
<SimpleTooltip tooltip="Clear chat">
<button
type="button"
onClick={chat.clearMessages}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
aria-label="Clear chat"
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</SimpleTooltip>
)}
<button
type="button"
onClick={() => onOpenChange(false)}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
aria-label="Close"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
</DialogHeader>
<div className="shrink-0 border-b border-black/[0.06] bg-zinc-50/80 dark:border-border/40 dark:bg-muted/20">
<div className="flex items-center justify-between px-5 py-2">
<Label className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Current query
</Label>
<div className="flex items-center gap-1">
{hasUnappliedCurrentQueryEdits && (
<SimpleTooltip tooltip="Apply query">
<button
type="button"
onClick={applyCurrentQueryDraft}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
>
<ArrowClockwiseIcon className="h-3 w-3" />
</button>
</SimpleTooltip>
)}
{canActOnQuery && (
<SimpleTooltip tooltip={copied ? "Copied!" : "Copy SQL"}>
<button
type="button"
onClick={() => runAsynchronously(handleCopy())}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
>
{copied ? (
<CheckIcon className="h-3 w-3 text-green-400" />
) : (
<CopyIcon className="h-3 w-3" />
)}
</button>
</SimpleTooltip>
)}
</div>
</div>
<div className="px-5 pb-3">
<Textarea
value={currentQueryDraft}
onChange={(e) => setCurrentQueryDraft(e.target.value)}
onBlur={applyCurrentQueryDraft}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
applyCurrentQueryDraft();
}
}}
placeholder="Ask the AI a question to generate a query."
className="min-h-16 max-h-32 resize-y overflow-auto border-black/[0.08] bg-white/95 font-mono text-[11px] text-foreground/90 shadow-sm ring-1 ring-black/[0.06] placeholder:italic placeholder:text-muted-foreground dark:border-border/40 dark:bg-background/60 dark:ring-white/[0.06]"
/>
</div>
</div>
{chat.error && (
<div className="mx-5 mt-3 shrink-0 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-[12px] text-red-300">
{chat.error.message || "Failed to get a response."}
</div>
)}
<div className="flex flex-1 min-h-0 flex-col">
<AssistantRuntimeProvider runtime={chat.runtime}>
<Thread
composerPlaceholder="Refine the query..."
runningStatusMessages={["Thinking..."]}
assistantContentComponents={assistantContentComponents}
welcome={<AiQueryWelcome />}
/>
</AssistantRuntimeProvider>
</div>
<DialogFooter className="px-5 py-3 border-t border-border/40 sm:justify-between gap-2">
<span className="text-[11px] text-muted-foreground">
Save the query or turn it into a live dashboard.
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
disabled={!canActOnQuery}
onClick={() => setSaveOpen(true)}
className="gap-1.5"
>
<FloppyDiskIcon className="h-3.5 w-3.5" />
Save query
</Button>
<Button
size="sm"
disabled={!canActOnQuery}
onClick={() => setBuildOpen(true)}
className="gap-1.5"
>
<LayoutIcon className="h-3.5 w-3.5" />
Build dashboard
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<SaveQueryInlineDialog
open={saveOpen}
onOpenChange={setSaveOpen}
sqlQuery={currentQueryDraft}
/>
<BuildDashboardDialog
open={buildOpen}
onOpenChange={setBuildOpen}
sqlQuery={currentQueryDraft}
/>
</>
);
}

View File

@ -9,13 +9,13 @@ import { useCallback, useState } from "react";
import { AppEnabledGuard } from "../../app-enabled-guard";
import { PageLayout } from "../../page-layout";
import { AnalyticsEventLimitBanner } from "../shared";
import { AiQueryBar } from "./ai-query-bar";
import { AiQueryDialog } from "./ai-query-dialog";
import {
QueryDataGrid,
type QueryDataGridMode,
} from "./query-data-grid";
import { getValidatedTableFilterQuery } from "./search-bar-logic";
import { TableSearchBar } from "./table-search-bar";
import { useAiTableFilterChat } from "./use-ai-table-filter-chat";
import { useAiQueryChat } from "./use-ai-query-chat";
// ─── Available tables ───────────────────────────────────────────────
@ -143,43 +143,56 @@ const AVAILABLE_TABLES = new Map<TableId, TableConfig>([
function TableContent({ tableId }: { tableId: TableId }) {
const tableConfig = AVAILABLE_TABLES.get(tableId) ?? throwErr(`Unknown analytics table: ${tableId}`);
const [dialogOpen, setDialogOpen] = useState(false);
// AI thread behind the search bar — constrained to row filters over this
// table, so the grid's columns never change.
const filterChat = useAiTableFilterChat(tableId);
// Shared AI chat state — feeds both the search bar and the eye
// dialog, so they operate on a single conversation thread.
const chat = useAiQueryChat();
const rawFilterQuery = filterChat.latestQuery;
// Defense-in-depth: the system prompt promises `SELECT * FROM <table>
// WHERE ...`, but the model could still emit anything — validate the shape
// before letting it drive the grid, otherwise the columns could change.
const filterQuery = rawFilterQuery == null ? null : getValidatedTableFilterQuery(rawFilterQuery, tableId);
const filterRejected = rawFilterQuery != null && filterQuery == null;
const aiQuery = chat.latestQuery;
const isAiActive = aiQuery != null;
const effectiveQuery = filterQuery ?? tableConfig.baseQuery;
const effectiveMode: QueryDataGridMode = filterQuery != null ? "one-shot" : "paginated";
// When the AI has committed a query, it becomes the source of
// truth; otherwise fall back to the table's own default query.
const effectiveQuery = aiQuery ?? tableConfig.baseQuery;
const effectiveMode: QueryDataGridMode = isAiActive ? "one-shot" : "paginated";
const renderToolbarExtra = useCallback(
(ctx: { rowCount: number, hasMore: boolean }) => (
<span className="hidden h-[22px] shrink-0 items-center rounded-full bg-foreground/[0.04] px-2 text-[10px] tabular-nums text-muted-foreground ring-1 ring-foreground/[0.06] sm:inline-flex">
{ctx.hasMore
? `${ctx.rowCount.toLocaleString()}+ rows`
: `${ctx.rowCount.toLocaleString()} rows`}
</span>
),
[],
// Default sort / search only apply while the AI is inactive — an
// AI-generated aggregate won't have an `event_at` column to sort on.
const defaultOrderBy = isAiActive ? undefined : tableConfig.defaultOrderBy;
const defaultOrderDir = isAiActive ? undefined : tableConfig.defaultOrderDir;
const handleResetChat = useCallback(() => {
chat.clearMessages();
}, [chat]);
const aiSearchBar = (
<AiQueryBar
chat={chat}
isActive={isAiActive}
onOpenDialog={() => setDialogOpen(true)}
onReset={handleResetChat}
/>
);
const renderToolbarActions = useCallback(
(ctx: { reload: () => void }) => (
<Button
variant="ghost"
onClick={ctx.reload}
className="h-7 gap-1.5 px-2.5 text-[11px] font-medium text-muted-foreground hover:text-foreground"
title="Refresh"
>
<ArrowClockwiseIcon className="h-3.5 w-3.5" />
Refresh
</Button>
const renderToolbarExtra = useCallback(
(ctx: { rowCount: number, hasMore: boolean, reload: () => void }) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={ctx.reload}
className="h-7 w-7"
title="Refresh"
>
<ArrowClockwiseIcon className="h-3.5 w-3.5" />
</Button>
<span className="hidden sm:inline px-1 text-[11px] tabular-nums text-muted-foreground">
{ctx.hasMore
? `${ctx.rowCount.toLocaleString()}+ rows`
: `${ctx.rowCount.toLocaleString()} rows`}
</span>
</div>
),
[],
);
@ -191,26 +204,23 @@ function TableContent({ tableId }: { tableId: TableId }) {
<QueryDataGrid
query={effectiveQuery}
mode={effectiveMode}
defaultOrderBy={tableConfig.defaultOrderBy}
defaultOrderDir={tableConfig.defaultOrderDir}
searchBar={(ctx) => (
<TableSearchBar
ctx={ctx}
queryKey={effectiveQuery}
chat={filterChat}
activeFilterQuery={filterQuery}
filterRejected={filterRejected}
onAiSubmit={(text) => filterChat.sendMessage({ text })}
onClearFilter={filterChat.clearMessages}
/>
)}
defaultOrderBy={defaultOrderBy}
defaultOrderDir={defaultOrderDir}
enableQuickSearchFilter={!isAiActive}
searchBar={aiSearchBar}
toolbarExtra={renderToolbarExtra}
toolbarActions={renderToolbarActions}
exportFilename={`${tableId}-export`}
fillHeight
stickyTop={0}
horizontalScrollbarPosition="top"
/>
<AiQueryDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
chat={chat}
currentQuery={aiQuery}
/>
</div>
);
}
@ -230,7 +240,7 @@ export default function PageClient() {
{/* Match the primary nav's dark:rounded-2xl so the gap junction mirrors
the same radius on both sides (nav top-right tables top-left). */}
<div className="flex min-h-0 flex-1 overflow-hidden rounded-l-2xl rounded-tr-2xl lg:ml-0.5">
<div className="flex min-h-0 flex-1 overflow-hidden rounded-l-2xl rounded-tr-2xl lg:-ml-2">
{/* Use the same surface treatment as the primary sidebar so equal radii render
identically. Omit the right border to keep the sidebar/grid junction divider-free. */}
<div className="hidden w-48 min-h-0 flex-shrink-0 flex-col overflow-hidden rounded-l-2xl bg-black/[0.03] dark:border dark:border-r-0 dark:border-foreground/5 dark:bg-foreground/5 dark:backdrop-blur-2xl dark:shadow-sm lg:flex">
@ -276,7 +286,7 @@ export default function PageClient() {
// Toolbar row only (first child of sticky chrome) — analytics layout
"[&_[role=grid]_.sticky>div:first-child>div]:pt-3",
"[&_[role=grid]_.sticky>div:first-child>div]:pb-2.5",
"[&_[role=grid]_.sticky>div:first-child>div]:pr-3",
"[&_[role=grid]_.sticky>div:first-child>div]:pr-0",
"[&_[role=grid]_.sticky>div:first-child>div]:pl-2.5",
)}
style={{

View File

@ -108,21 +108,13 @@ export type QueryDataGridProps = {
| ReactNode
| ((ctx: QueryDataGridToolbarContext<RowData>) => ReactNode),
/**
* Extra content slotted into the default toolbar's leading cluster
* (after search), e.g. filters or a row-count badge. Can be a node or
* a function receiving the extended context.
* Extra content slotted into the default toolbar, to the LEFT of
* the built-in columns / export actions. Can be a node or a function
* receiving the extended context.
*/
toolbarExtra?:
| ReactNode
| ((ctx: QueryDataGridToolbarContext<RowData>) => ReactNode),
/**
* Extra content in the trailing action cluster, immediately before
* Columns / Export (e.g. a labeled Refresh). Can be a node or a
* function receiving the extended context.
*/
toolbarActions?:
| ReactNode
| ((ctx: QueryDataGridToolbarContext<RowData>) => ReactNode),
/** Whether the default row-click-to-inspect dialog is enabled. Defaults to `true`. */
enableRowDetailDialog?: boolean,
/** Custom row click handler. Overrides the default row detail dialog. */
@ -330,7 +322,6 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
toolbar,
searchBar,
toolbarExtra,
toolbarActions,
enableRowDetailDialog = true,
onRowClick,
onError,
@ -382,12 +373,12 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
};
});
// Whenever the query changes, reset sort / search / page so a leftover
// ORDER BY from a previous schema can't crash the next fetch. Keep the
// last discovered columns — clearing them would leave the grid with an
// empty schema during the reload (blank pane instead of a shimmer), and
// the next successful page overwrites them anyway.
// Whenever the query changes we want fresh columns + a reset sort
// so a leftover sort from a previous schema doesn't crash the
// next query (nonexistent column in ORDER BY).
useEffect(() => {
setDiscoveredColumns([]);
discoveredColumnsRef.current = [];
setError(null);
setGridState((prev) => ({
...prev,
@ -442,10 +433,6 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
[discoveredColumns],
);
// `query` / `mode` are in the dep list so the generator identity changes
// when the SQL changes — useDataSource keys off that to refetch. The
// generator still reads through refs so a single in-flight page always
// sees the latest values.
const dataSource = useMemo<DataGridDataSource<RowData>>(() => {
return async function* (params) {
setError(null);
@ -511,7 +498,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
yield { rows: [], hasMore: false };
}
};
}, [serverApp, query, mode]);
}, [serverApp]);
const getRowId = useCallback((row: RowData): string => {
if (typeof row[INTERNAL_ROW_ID_KEY] === "string") return row[INTERNAL_ROW_ID_KEY];
@ -581,8 +568,8 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
* 2. `searchBar` provided render our own DataGridToolbar
* wrapper that hides the built-in quick search and slots the
* caller's node where it used to live; keeps Columns/Export
* intact. `toolbarExtra` / `toolbarActions` (if provided) are
* passed through to the leading / trailing slots.
* intact. `toolbarExtra` (if provided) is passed through as
* the built-in extras slot.
* 3. neither undefined, so the DataGrid
* falls back to its default toolbar behaviour (built-in
* quick search, extras, columns, export).
@ -598,23 +585,16 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
: typeof toolbarExtra === "function"
? toolbarExtra(extended)
: toolbarExtra;
const actions =
toolbarActions === undefined
? undefined
: typeof toolbarActions === "function"
? toolbarActions(extended)
: toolbarActions;
return (
<DataGridToolbar
ctx={ctx}
extra={extras}
extraLeading={leading}
extraActions={actions}
hideQuickSearch
/>
);
},
[searchBar, toolbarExtra, toolbarActions, extendCtx],
[searchBar, toolbarExtra, extendCtx],
);
const renderForwardedToolbar = useCallback(
@ -686,11 +666,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
emptyState={
emptyState ?? (
<div className="flex flex-col items-center justify-center gap-4 py-16">
<Typography variant="secondary">
{gridState.quickSearch.trim().length > 0
? "No rows match your search"
: "No data available"}
</Typography>
<Typography variant="secondary">No data available</Typography>
</div>
)
}