mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
fixes and suggestions implemented
This commit is contained in:
parent
2ab581076f
commit
54167b7f63
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Link } from "@/components/link";
|
||||
import { DesignSelectorDropdown } from "@/components/design-components/select";
|
||||
import { Button, Typography } from "@/components/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowClockwiseIcon, CodeIcon } from "@phosphor-icons/react";
|
||||
@ -13,7 +14,6 @@ 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";
|
||||
|
||||
@ -139,6 +139,10 @@ const AVAILABLE_TABLES = new Map<TableId, TableConfig>([
|
||||
],
|
||||
]);
|
||||
|
||||
const AVAILABLE_TABLE_OPTIONS = [...AVAILABLE_TABLES.entries()].map(
|
||||
([value, config]) => ({ value, label: config.displayName }),
|
||||
);
|
||||
|
||||
// ─── Per-table content ──────────────────────────────────────────────
|
||||
|
||||
function TableContent({ tableId }: { tableId: TableId }) {
|
||||
@ -148,12 +152,7 @@ function TableContent({ tableId }: { tableId: TableId }) {
|
||||
// table, so the grid's columns never change.
|
||||
const filterChat = useAiTableFilterChat(tableId);
|
||||
|
||||
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 filterQuery = filterChat.latestQuery;
|
||||
|
||||
const effectiveQuery = filterQuery ?? tableConfig.baseQuery;
|
||||
const effectiveMode: QueryDataGridMode = filterQuery != null ? "one-shot" : "paginated";
|
||||
@ -199,7 +198,7 @@ function TableContent({ tableId }: { tableId: TableId }) {
|
||||
queryKey={effectiveQuery}
|
||||
chat={filterChat}
|
||||
activeFilterQuery={filterQuery}
|
||||
filterRejected={filterRejected}
|
||||
filterRejected={filterChat.filterRejected}
|
||||
onAiSubmit={(text) => filterChat.sendMessage({ text })}
|
||||
onClearFilter={filterChat.clearMessages}
|
||||
/>
|
||||
@ -228,9 +227,25 @@ export default function PageClient() {
|
||||
<AnalyticsEventLimitBanner />
|
||||
</div>
|
||||
|
||||
{/* 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="shrink-0 px-3 py-2 lg:hidden">
|
||||
<label htmlFor="analytics-table-selector" className="sr-only">
|
||||
Table
|
||||
</label>
|
||||
<DesignSelectorDropdown
|
||||
value={selectedTable ?? "events"}
|
||||
onValueChange={setSelectedTable}
|
||||
options={AVAILABLE_TABLE_OPTIONS}
|
||||
placeholder="Select a table"
|
||||
size="sm"
|
||||
triggerId="analytics-table-selector"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dark: match the primary nav's rounded-2xl so the gap junction mirrors
|
||||
the same radius on both sides (nav top-right ↔ tables top-left).
|
||||
Light: only round the left edge — the shell card already owns the
|
||||
top-right radius, so an inner tr curve reads as a stray notch. */}
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden rounded-l-2xl dark:rounded-tr-2xl lg:ml-0.5">
|
||||
{/* 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">
|
||||
@ -267,7 +282,11 @@ export default function PageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right content — grid fills remaining height and scrolls internally */}
|
||||
{/* Right content — grid fills remaining height and scrolls internally.
|
||||
Sticky chrome is its own composited layer and paints square over a
|
||||
parent's overflow:hidden radius, so dark mode clips the panel itself.
|
||||
Below lg the tables sidebar is hidden and the grid owns both top
|
||||
corners; at lg+ the sidebar owns the left radius. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
@ -278,14 +297,9 @@ export default function PageClient() {
|
||||
"[&_[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]:pl-2.5",
|
||||
"max-lg:dark:rounded-t-2xl max-lg:dark:[clip-path:inset(0_round_1rem_1rem_0_0)]",
|
||||
"lg:dark:rounded-tr-2xl lg:dark:[clip-path:inset(0_round_0_1rem_0_0)]",
|
||||
)}
|
||||
style={{
|
||||
// Outer top-right corner of the block. Same clip-path trick as the
|
||||
// sidebar: the grid's sticky chrome is its own composited layer and
|
||||
// paints square over the parent's rounded overflow clip without this.
|
||||
borderTopRightRadius: "1rem",
|
||||
clipPath: "inset(0 round 0 1rem 0 0)",
|
||||
}}
|
||||
>
|
||||
{selectedTable ? (
|
||||
<TableContent key={selectedTable} tableId={selectedTable} />
|
||||
|
||||
@ -578,11 +578,10 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
*
|
||||
* Priority:
|
||||
* 1. `toolbar` (full override) — caller owns the whole row
|
||||
* 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.
|
||||
* 2. `searchBar` or `toolbarActions` provided — render our own
|
||||
* DataGridToolbar wrapper. The built-in quick search is hidden only
|
||||
* when `searchBar` replaces it; Columns/Export stay intact and the
|
||||
* leading/trailing extension slots are passed through.
|
||||
* 3. neither — undefined, so the DataGrid
|
||||
* falls back to its default toolbar behaviour (built-in
|
||||
* quick search, extras, columns, export).
|
||||
@ -610,7 +609,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
extra={extras}
|
||||
extraLeading={leading}
|
||||
extraActions={actions}
|
||||
hideQuickSearch
|
||||
hideQuickSearch={searchBar !== undefined}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@ -627,7 +626,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
|
||||
const resolvedToolbar = toolbar
|
||||
? renderForwardedToolbar
|
||||
: searchBar !== undefined
|
||||
: searchBar !== undefined || toolbarActions !== undefined
|
||||
? renderCustomToolbar
|
||||
: undefined;
|
||||
|
||||
@ -635,11 +634,11 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
// When we've already built a custom toolbar above for the
|
||||
// `searchBar` case, the `toolbarExtra` prop is consumed inside
|
||||
// that custom toolbar — don't also pass it to DataGrid.
|
||||
if (toolbar || searchBar !== undefined) return undefined;
|
||||
if (toolbar || searchBar !== undefined || toolbarActions !== undefined) return undefined;
|
||||
if (toolbarExtra === undefined) return undefined;
|
||||
if (typeof toolbarExtra !== "function") return toolbarExtra;
|
||||
return (ctx: DataGridToolbarContext<RowData>) => toolbarExtra(extendCtx(ctx));
|
||||
}, [toolbar, searchBar, toolbarExtra, extendCtx]);
|
||||
}, [toolbar, searchBar, toolbarActions, toolbarExtra, extendCtx]);
|
||||
|
||||
return (
|
||||
<div className={fillHeight ? "flex flex-1 min-h-0 flex-col" : "flex flex-col"}>
|
||||
|
||||
@ -70,6 +70,24 @@ describe("getValidatedTableFilterQuery", () => {
|
||||
expect(getValidatedTableFilterQuery(query, "users")).toBe(query);
|
||||
});
|
||||
|
||||
it("rejects top-level sorting and pagination while allowing them in subqueries", () => {
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users ORDER BY signed_up_at DESC",
|
||||
"users",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery("SELECT * FROM users LIMIT 100", "users"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE toString(id) IN (SELECT user_id FROM events ORDER BY event_at DESC LIMIT 100)",
|
||||
"users",
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects queries on a different table", () => {
|
||||
expect(getValidatedTableFilterQuery("SELECT * FROM events", "users")).toBe(null);
|
||||
// prefix of another table name must not match
|
||||
|
||||
@ -77,9 +77,11 @@ function escapeRegExp(value: string): string {
|
||||
/**
|
||||
* Validates that an AI-committed query is a pure row filter over the given
|
||||
* table, i.e. it cannot change the grid's columns. Accepts
|
||||
* `SELECT * FROM [default.]<table>` optionally followed by
|
||||
* WHERE/PREWHERE/ORDER BY/LIMIT — anything else (column lists, JOINs at the
|
||||
* top level, other tables) returns null and must not be applied to the grid.
|
||||
* `SELECT * FROM [default.]<table>` optionally followed by a WHERE/PREWHERE
|
||||
* row filter — anything else (column lists, JOINs, sorting, pagination, other
|
||||
* tables) returns null and must not be applied to the grid. Sorting and
|
||||
* pagination stay grid-owned so an AI filter cannot silently truncate or
|
||||
* reorder the result set before the grid processes it.
|
||||
* Subqueries inside the WHERE condition are fine: the `SELECT *` prefix on
|
||||
* the outer query is what guarantees the column set stays identical.
|
||||
*
|
||||
@ -95,7 +97,7 @@ export function getValidatedTableFilterQuery(
|
||||
const collapsed = normalized.replace(/\s+/g, " ");
|
||||
const table = escapeRegExp(tableName);
|
||||
const pattern = new RegExp(
|
||||
`^select \\* from (?:default\\.)?\`?${table}\`?(?: (?:where|prewhere|order by|limit)\\b.*)?$`,
|
||||
`^select \\* from (?:default\\.)?\`?${table}\`?(?: (?:where|prewhere)\\b.*)?$`,
|
||||
"i",
|
||||
);
|
||||
return pattern.test(collapsed) ? normalized : null;
|
||||
|
||||
@ -238,19 +238,9 @@ export function TableSearchBar({
|
||||
applyQuickSearch("");
|
||||
}, [applyQuickSearch]);
|
||||
|
||||
// Chip label: the natural-language request that produced the active filter.
|
||||
const activeFilterLabel = useMemo(() => {
|
||||
if (activeFilterQuery == null) return null;
|
||||
for (let i = chat.messages.length - 1; i >= 0; i--) {
|
||||
const msg = chat.messages[i]!;
|
||||
if (msg.role !== "user") continue;
|
||||
const part = msg.content.find((p) => p.type === "text");
|
||||
if (part?.type === "text" && part.text.trim().length > 0) {
|
||||
return part.text.trim();
|
||||
}
|
||||
}
|
||||
return "AI filter";
|
||||
}, [activeFilterQuery, chat.messages]);
|
||||
// Stored with the committed query so a rejected/text-only refinement cannot
|
||||
// relabel the still-active filter with the newer request.
|
||||
const activeFilterLabel = activeFilterQuery == null ? null : chat.latestQueryLabel;
|
||||
|
||||
const rejectionMessage = filterRejected
|
||||
? "The AI answered with a query that would change this table's columns, so it wasn't applied. Try rephrasing your request."
|
||||
|
||||
@ -4,7 +4,7 @@ import { extractLatestQuery } from "./use-ai-table-filter-chat";
|
||||
|
||||
const fixture = (messages: Array<{
|
||||
id: string,
|
||||
role: "assistant",
|
||||
role: "user" | "assistant",
|
||||
content: Array<
|
||||
| { type: "text", text: string }
|
||||
| { type: "tool-call", toolCallId: string, toolName: string, args: { query?: string }, argsText?: string, result: unknown }
|
||||
@ -14,6 +14,11 @@ const fixture = (messages: Array<{
|
||||
describe("extractLatestQuery", () => {
|
||||
it("ignores failed queryAnalytics tool calls and keeps the last successful query", () => {
|
||||
const messages = fixture([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "verified users" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
@ -28,6 +33,11 @@ describe("extractLatestQuery", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "only recent users" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-2",
|
||||
role: "assistant",
|
||||
@ -48,7 +58,55 @@ describe("extractLatestQuery", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
query: "SELECT 1",
|
||||
toolCallIndex: 2,
|
||||
toolCallIndex: 1,
|
||||
requestText: "verified users",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse a successful query from before the supplied run", () => {
|
||||
const currentRun = fixture([
|
||||
{
|
||||
id: "user-2",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "group them by month" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-2",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "This table view cannot show grouped results." }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(extractLatestQuery(currentRun)).toBeNull();
|
||||
});
|
||||
|
||||
it("associates a successful query with the request that produced it", () => {
|
||||
const messages = fixture([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "verified users" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "queryAnalytics",
|
||||
args: { query: "SELECT * FROM users WHERE primary_email_verified = 1" },
|
||||
argsText: "",
|
||||
result: { success: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(extractLatestQuery(messages)).toEqual({
|
||||
query: "SELECT * FROM users WHERE primary_email_verified = 1",
|
||||
toolCallIndex: 1,
|
||||
requestText: "verified users",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { useUser } from "@hexclave/next";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useProjectId } from "../../use-admin-app";
|
||||
import { getValidatedTableFilterQuery } from "./search-bar-logic";
|
||||
|
||||
const QUERY_ANALYTICS_TOOL = "queryAnalytics";
|
||||
|
||||
@ -33,29 +34,42 @@ function isSuccessfulQueryToolPart(part: QueryToolPart): boolean {
|
||||
export function extractLatestQuery(messages: readonly ThreadMessage[]): {
|
||||
query: string,
|
||||
toolCallIndex: number,
|
||||
requestText: string,
|
||||
} | null {
|
||||
let toolCallIndex = 0;
|
||||
let requestText: string | null = null;
|
||||
let latest: {
|
||||
query: string,
|
||||
toolCallIndex: number,
|
||||
requestText: string,
|
||||
} | null = null;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
const textPart = msg.content.find((part) => part.type === "text");
|
||||
if (textPart?.type === "text" && textPart.text.trim().length > 0) {
|
||||
requestText = textPart.text.trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.content) {
|
||||
if (isQueryAnalyticsToolPart(part)) toolCallIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!;
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (let j = msg.content.length - 1; j >= 0; j--) {
|
||||
const part = msg.content[j]!;
|
||||
if (!isQueryAnalyticsToolPart(part)) continue;
|
||||
toolCallIndex += 1;
|
||||
if (!isSuccessfulQueryToolPart(part)) continue;
|
||||
const query = typeof part.args.query === "string" ? part.args.query : null;
|
||||
if (query && query.trim().length > 0) {
|
||||
return { query, toolCallIndex };
|
||||
latest = {
|
||||
query,
|
||||
toolCallIndex,
|
||||
requestText: requestText ?? throwErr(
|
||||
"queryAnalytics returned a filter without a preceding user request",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -83,8 +97,12 @@ export type AiTableFilterChat = {
|
||||
error: Error | null,
|
||||
sendMessage: (input: { text: string }) => void,
|
||||
clearMessages: () => void,
|
||||
/** The last query the AI committed via the queryAnalytics tool (unvalidated). */
|
||||
/** The last validated row filter committed via the queryAnalytics tool. */
|
||||
latestQuery: string | null,
|
||||
/** The user request that produced `latestQuery`. */
|
||||
latestQueryLabel: string | null,
|
||||
/** Whether the latest run produced a query that failed row-filter validation. */
|
||||
filterRejected: boolean,
|
||||
/**
|
||||
* Set when the last run finished WITHOUT committing a new query — holds the
|
||||
* assistant's text reply (or null if there was none / a query was
|
||||
@ -97,9 +115,8 @@ export type AiTableFilterChat = {
|
||||
/**
|
||||
* AI chat thread backing the analytics table search bar's AI fallback. Uses
|
||||
* the constrained `filter-analytics-table` system prompt scoped to the given
|
||||
* table, so the AI only produces `SELECT * FROM <table> WHERE ...` row
|
||||
* filters (callers still validate the shape before applying — see
|
||||
* `getValidatedTableFilterQuery`).
|
||||
* table, and validates every committed tool call before exposing it as the
|
||||
* active `SELECT * FROM <table> WHERE ...` row filter.
|
||||
*/
|
||||
export function useAiTableFilterChat(tableName: string): AiTableFilterChat {
|
||||
const currentUser = useUser();
|
||||
@ -147,43 +164,53 @@ export function useAiTableFilterChat(tableName: string): AiTableFilterChat {
|
||||
|
||||
const [committed, setCommitted] = useState<{
|
||||
query: string,
|
||||
generation: number,
|
||||
label: string,
|
||||
} | null>(null);
|
||||
const [filterRejected, setFilterRejected] = useState(false);
|
||||
const [assistantNote, setAssistantNote] = useState<string | null>(null);
|
||||
const wasRespondingRef = useRef(false);
|
||||
const lastCommittedGenRef = useRef(0);
|
||||
const runStartMessageIndexRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const justFinished = wasRespondingRef.current && !isResponding;
|
||||
wasRespondingRef.current = isResponding;
|
||||
if (!justFinished) return;
|
||||
|
||||
const latest = extractLatestQuery(messages);
|
||||
if (latest == null || latest.toolCallIndex <= lastCommittedGenRef.current) {
|
||||
// Each falling edge is evaluated only against messages added by that run.
|
||||
// Scanning the whole thread would let a failed/text-only refinement pick
|
||||
// up and recommit an older successful query (and its unrelated reply).
|
||||
const runMessages = messages.slice(runStartMessageIndexRef.current);
|
||||
const latest = extractLatestQuery(runMessages);
|
||||
if (latest == null) {
|
||||
// The run ended without committing a new query — surface the
|
||||
// assistant's text (if any) so callers can show WHY nothing changed.
|
||||
// This must live here (not in a consuming component) because child
|
||||
// effects run before this one; a child comparing commit state on the
|
||||
// same falling edge would race the commit below.
|
||||
setAssistantNote(extractLastAssistantText(messages));
|
||||
setAssistantNote(extractLastAssistantText(runMessages));
|
||||
return;
|
||||
}
|
||||
lastCommittedGenRef.current = latest.toolCallIndex;
|
||||
setCommitted({ query: latest.query, generation: latest.toolCallIndex });
|
||||
setAssistantNote(null);
|
||||
}, [isResponding, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0 && committed != null) {
|
||||
lastCommittedGenRef.current = 0;
|
||||
setCommitted(null);
|
||||
const validatedQuery = getValidatedTableFilterQuery(latest.query, tableName);
|
||||
if (validatedQuery == null) {
|
||||
// Keep the previous valid filter active. A rejected refinement should
|
||||
// never reset the grid to its unfiltered base query or relabel the chip.
|
||||
setFilterRejected(true);
|
||||
setAssistantNote(null);
|
||||
return;
|
||||
}
|
||||
}, [messages.length, committed]);
|
||||
|
||||
setCommitted({ query: validatedQuery, label: latest.requestText });
|
||||
setFilterRejected(false);
|
||||
setAssistantNote(null);
|
||||
}, [isResponding, messages, tableName]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
({ text }: { text: string }) => {
|
||||
setError(null);
|
||||
setAssistantNote(null);
|
||||
setFilterRejected(false);
|
||||
runStartMessageIndexRef.current = runtime.thread.getState().messages.length;
|
||||
runtime.thread.append({
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
@ -195,6 +222,9 @@ export function useAiTableFilterChat(tableName: string): AiTableFilterChat {
|
||||
const clearMessages = useCallback(() => {
|
||||
setError(null);
|
||||
setAssistantNote(null);
|
||||
setFilterRejected(false);
|
||||
setCommitted(null);
|
||||
runStartMessageIndexRef.current = 0;
|
||||
runtime.thread.import({ messages: [], headId: null });
|
||||
}, [runtime]);
|
||||
|
||||
@ -209,6 +239,8 @@ export function useAiTableFilterChat(tableName: string): AiTableFilterChat {
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
latestQuery: committed?.query ?? null,
|
||||
latestQueryLabel: committed?.label ?? null,
|
||||
filterRejected,
|
||||
assistantNote,
|
||||
dismissAssistantNote,
|
||||
};
|
||||
|
||||
@ -330,6 +330,7 @@ export function DataGridToolbar<TRow>({
|
||||
() => columns.some((c) => c.type === "date" || c.type === "dateTime"),
|
||||
[columns],
|
||||
);
|
||||
const hasExtraActions = React.Children.toArray(extraActions).length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-2 px-2.5 py-2.5 pr-3 border-b border-foreground/[0.06] sm:flex-row sm:items-center sm:gap-2">
|
||||
@ -345,7 +346,7 @@ export function DataGridToolbar<TRow>({
|
||||
{extra}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-1.5">
|
||||
{extraActions != null && (
|
||||
{hasExtraActions && (
|
||||
<>
|
||||
{extraActions}
|
||||
<div
|
||||
|
||||
@ -5,7 +5,9 @@ import { useMemo, useState } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDefaultDataGridState,
|
||||
DATA_GRID_DEFAULT_STRINGS,
|
||||
DataGrid,
|
||||
DataGridToolbar,
|
||||
isDataGridInteractiveRowClickTarget,
|
||||
useDataSource,
|
||||
type DataGridColumnDef,
|
||||
@ -49,6 +51,41 @@ const wideColumns: DataGridColumnDef<Row>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
describe("DataGridToolbar action divider", () => {
|
||||
const ctx = {
|
||||
state: createDefaultDataGridState(columns),
|
||||
onChange: vi.fn(),
|
||||
columns,
|
||||
visibleColumns: columns,
|
||||
totalRowCount: 1,
|
||||
selectedRowCount: 0,
|
||||
strings: DATA_GRID_DEFAULT_STRINGS,
|
||||
exportCsv: vi.fn(),
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("does not render a divider for an empty React node", () => {
|
||||
const { container } = render(
|
||||
<DataGridToolbar ctx={ctx} extraActions={false} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx-0\\.5.h-4.w-px")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the divider when an action is visible", () => {
|
||||
const { container, getByRole } = render(
|
||||
<DataGridToolbar
|
||||
ctx={ctx}
|
||||
extraActions={<button type="button">Refresh</button>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByRole("button", { name: "Refresh" })).not.toBeNull();
|
||||
expect(container.querySelector(".mx-0\\.5.h-4.w-px")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
type ObserverRecord = {
|
||||
options?: IntersectionObserverInit,
|
||||
};
|
||||
|
||||
@ -62,6 +62,12 @@ import type {
|
||||
// (no `fillHeight`, no `maxHeight`). Leaves ~16rem of room for the top bar, page header, and grid
|
||||
// toolbar. See the `effectiveMaxHeight` comment in DataGrid for why an infinite grid must be bounded.
|
||||
const DEFAULT_INFINITE_MAX_HEIGHT = "calc(100dvh - 16rem)";
|
||||
const DATA_GRID_SCROLLBAR_CLASS_NAME = cn(
|
||||
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
|
||||
);
|
||||
|
||||
// ─── Row click target ────────────────────────────────────────────────
|
||||
|
||||
@ -1073,19 +1079,6 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
const horizontalScrollbarAtTop = horizontalScrollbarPosition === "top";
|
||||
// Shared thin thumb styling. When the bar is on top, the header scrollport shows
|
||||
// it; the body keeps overflow-x hidden so a second thumb doesn't appear at the bottom.
|
||||
const headerScrollbarClassName = cn(
|
||||
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
|
||||
);
|
||||
const bodyScrollbarClassName = cn(
|
||||
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
|
||||
);
|
||||
|
||||
// Trackpad / shift-wheel horizontal gestures land on the body; with the top
|
||||
// scrollbar the body has overflow-x:hidden, so forward deltaX to the header.
|
||||
const handleBodyWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||
@ -1206,7 +1199,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
className={cn(
|
||||
"w-full min-w-0 shrink-0 border-b border-foreground/[0.06]",
|
||||
horizontalScrollbarAtTop
|
||||
? cn("overflow-x-auto overflow-y-hidden", headerScrollbarClassName)
|
||||
? cn("overflow-x-auto overflow-y-hidden", DATA_GRID_SCROLLBAR_CLASS_NAME)
|
||||
: "overflow-hidden",
|
||||
)}
|
||||
onScroll={horizontalScrollbarAtTop ? handleHeaderScroll : undefined}
|
||||
@ -1256,7 +1249,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
// under the rows. scrollLeft is still set programmatically from the header.
|
||||
horizontalScrollbarAtTop ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
||||
isBounded ? "min-h-0 flex-1" : "flex-none",
|
||||
bodyScrollbarClassName,
|
||||
DATA_GRID_SCROLLBAR_CLASS_NAME,
|
||||
)}
|
||||
onScroll={handleBodyScroll}
|
||||
onWheel={horizontalScrollbarAtTop ? handleBodyWheel : undefined}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user