diff --git a/web/oss/src/components/EvaluationRunsTablePOC/actions/navigationActions.ts b/web/oss/src/components/EvaluationRunsTablePOC/actions/navigationActions.ts index bf4160796b..8f1ac7dc66 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/actions/navigationActions.ts +++ b/web/oss/src/components/EvaluationRunsTablePOC/actions/navigationActions.ts @@ -1,5 +1,3 @@ -import type {MouseEvent} from "react" - import {message} from "@agenta/ui/app-message" import {getDefaultStore} from "jotai" import Router from "next/router" @@ -23,14 +21,6 @@ const getUrlState = (): URLState => store.get(urlAtom) as URLState const getActiveAppId = (): string | null => store.get(routerAppIdAtom) -export const shouldIgnoreRowClick = (event: MouseEvent) => { - const target = event.target as HTMLElement | null - if (!target) return false - const interactiveSelector = - "button, a, input, textarea, select, [role='button'], [role='menuitem'], [role='checkbox'], .ant-checkbox, .ant-checkbox-input, .ant-checkbox-inner, .ant-checkbox-wrapper, .ant-btn, .ant-select, .ant-dropdown-trigger" - return Boolean(target.closest(interactiveSelector)) -} - interface NavigateToRunParams { record: EvaluationRunTableRow scope: "app" | "project" diff --git a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx index 381c42a3c5..ad5225e964 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx +++ b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx @@ -1,4 +1,4 @@ -import type {Key, MouseEvent, ReactNode} from "react" +import type {Key, ReactNode} from "react" import {useCallback, useEffect, useMemo, useRef, useState} from "react" import {useQueryClient} from "@tanstack/react-query" @@ -34,7 +34,6 @@ import { } from "@/oss/lib/onboarding" import {useQueryParamState} from "@/oss/state/appState" -import {shouldIgnoreRowClick} from "../../actions/navigationActions" import { evaluationRunsDeleteContextAtom, evaluationRunsTableFetchEnabledAtom, @@ -333,8 +332,7 @@ const EvaluationRunsTableActive = ({ const runId = record.preview?.id ?? record.runId const isNavigable = Boolean(!record.__isSkeleton && runId) return { - onClick: (event: MouseEvent) => { - if (shouldIgnoreRowClick(event)) return + onClick: () => { if (!isNavigable) return handleOpenRun(record) }, diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx b/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx index 2abe631bc3..1dc8c6f722 100644 --- a/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx +++ b/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx @@ -203,6 +203,7 @@ function createActionsColumn( align: "center", // Lock actions column from being toggled in visibility menu columnVisibilityLocked: true as any, + onCell: () => ({className: "ag-table-actions-cell"}), render: (_, record) => { if (record.__isSkeleton) return null @@ -294,7 +295,10 @@ function createActionsColumn( } return ( -
+
e.stopPropagation()} + > ( ;(column as any).exportMetadata = config.exportMetadata } + // Auto-stop click propagation in action columns so clicks on empty cell area + // don't bubble to the row navigation handler. + if (config.key === "actions") { + const prevOnCell = column.onCell as ((record: Row, index?: number) => any) | undefined + column.onCell = (record: Row, index?: number) => { + const base = prevOnCell ? prevOnCell(record, index) : {} + const prevClick = (base as any)?.onClick + return { + ...base, + className: clsx((base as any)?.className, "ag-table-actions-cell"), + onClick: (e: MouseEvent) => { + e.stopPropagation() + prevClick?.(e) + }, + } + } + } + return column } diff --git a/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx b/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx index 7ca296bd19..3b07f92a9e 100644 --- a/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx +++ b/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx @@ -31,6 +31,7 @@ import useInfiniteScroll from "../hooks/useInfiniteScroll" import useScrollContainer from "../hooks/useScrollContainer" import useSmartResizableColumns from "../hooks/useSmartResizableColumns" import useTableKeyboardShortcuts from "../hooks/useTableKeyboardShortcuts" +import {shouldIgnoreRowClick} from "../hooks/useTableManager" import useTableRowSelection from "../hooks/useTableRowSelection" import ColumnVisibilityProvider from "../providers/ColumnVisibilityProvider" import type {InfiniteVirtualTableProps} from "../types" @@ -69,6 +70,7 @@ const InfiniteVirtualTableInnerBase = ({ keyboardShortcuts, expandable, tableRef, + disableInteractiveClickGuard = false, }: InfiniteVirtualTableInnerProps) => { const generatedScopeId = useId() const resolvedScopeId = useMemo( @@ -500,28 +502,43 @@ const InfiniteVirtualTableInnerBase = ({ const shortcutProps = getShortcutRowProps ? (getShortcutRowProps(record, index) ?? {}) : {} - if (!shortcutProps || Object.keys(shortcutProps).length === 0) { - return baseProps + + const baseOnClick = baseProps?.onClick + const guardedOnClick = + !disableInteractiveClickGuard && baseOnClick + ? (event: React.MouseEvent) => { + if (shouldIgnoreRowClick(event)) return + baseOnClick(event) + } + : baseOnClick + + const hasShortcuts = shortcutProps && Object.keys(shortcutProps).length > 0 + if (!hasShortcuts) { + if (guardedOnClick === baseOnClick) return baseProps + return {...baseProps, onClick: guardedOnClick} } return { ...baseProps, ...shortcutProps, className: clsx(baseProps?.className, shortcutProps?.className), onMouseEnter: mergeHandlers(baseProps?.onMouseEnter, shortcutProps?.onMouseEnter), + onClick: guardedOnClick, } }, - [finalTableProps.onRow, getShortcutRowProps], + [finalTableProps.onRow, getShortcutRowProps, disableInteractiveClickGuard], ) const tablePropsWithShortcuts = useMemo>(() => { - if (!getShortcutRowProps) { + const needsMerge = + getShortcutRowProps || (Boolean(finalTableProps.onRow) && !disableInteractiveClickGuard) + if (!needsMerge) { return finalTableProps } return { ...finalTableProps, onRow: mergedOnRow, } - }, [finalTableProps, getShortcutRowProps, mergedOnRow]) + }, [finalTableProps, getShortcutRowProps, mergedOnRow, disableInteractiveClickGuard]) const tableRowSelection = useTableRowSelection(rowSelection) diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx index be69f2bad5..2c3c610497 100644 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx +++ b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx @@ -26,27 +26,18 @@ import useTableExport from "./useTableExport" /** Stable no-op atom used when no external search atom is provided (hooks can't be conditional) */ const dummySearchAtom = atom("") +const INTERACTIVE_SELECTOR = + "button, a, input, textarea, select, [role='button'], [role='menuitem'], [role='checkbox'], " + + ".ant-btn, .ant-checkbox, .ant-checkbox-input, .ant-checkbox-inner, .ant-checkbox-wrapper, " + + ".ant-select, .ant-dropdown-trigger, .ant-table-selection-column, .ag-table-actions-cell" + /** - * Helper to detect if a click event should be ignored for row navigation - * Returns true if the click was on an interactive element (button, link, dropdown, etc.) + * Returns true when the click originated from an interactive element (button, link, + * dropdown, checkbox, etc.) and should not bubble up to the row navigation handler. */ export const shouldIgnoreRowClick = (event: MouseEvent): boolean => { - const target = event.target as HTMLElement - - // Check if clicking on interactive elements - if ( - target.closest("button") || - target.closest("a") || - target.closest(".ant-dropdown-trigger") || - target.closest(".ant-checkbox-wrapper") || - target.closest(".ant-select") || - target.closest("input") || - target.closest("textarea") - ) { - return true - } - - return false + const target = event.target as HTMLElement | null + return Boolean(target?.closest(INTERACTIVE_SELECTOR)) } /** Configuration for built-in search. When provided, the hook manages search state internally. */ diff --git a/web/oss/src/components/InfiniteVirtualTable/types.ts b/web/oss/src/components/InfiniteVirtualTable/types.ts index 2676bcebcf..f2d5c28dd3 100644 --- a/web/oss/src/components/InfiniteVirtualTable/types.ts +++ b/web/oss/src/components/InfiniteVirtualTable/types.ts @@ -276,6 +276,12 @@ export interface InfiniteVirtualTableProps resizableColumns?: boolean | ResizableColumnsConfig columnVisibility?: ColumnVisibilityConfig + /** + * When true, disables the built-in guard that prevents row-click navigation + * from firing when the click originates from an interactive element (button, + * checkbox, dropdown, etc.). Defaults to false — the guard is on by default. + */ + disableInteractiveClickGuard?: boolean onColumnToggle?: (payload: { scopeId: string | null columnKey: string diff --git a/web/oss/src/components/TestcasesTableNew/components/TestcaseSelectionCell.tsx b/web/oss/src/components/TestcasesTableNew/components/TestcaseSelectionCell.tsx index 7a15b9b4a2..a90b1c9e84 100644 --- a/web/oss/src/components/TestcasesTableNew/components/TestcaseSelectionCell.tsx +++ b/web/oss/src/components/TestcasesTableNew/components/TestcaseSelectionCell.tsx @@ -50,6 +50,7 @@ const TestcaseSelectionCell = memo(function TestcaseSelectionCell({ className="flex items-center justify-center w-full h-full absolute inset-0" title={tooltipTitle} style={showDirtyIndicator ? {backgroundColor: "rgb(255 251 235)"} : undefined} + onClick={(e) => e.stopPropagation()} >
{originNode}
diff --git a/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx b/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx index a046d6ec5c..e5f4b12be9 100644 --- a/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx +++ b/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx @@ -696,21 +696,27 @@ export function TestcasesTableShell(props: TestcasesTableShellProps) { align: "center", columnVisibilityLocked: true as any, exportEnabled: false as any, // Exclude from client-side CSV export + onCell: () => ({className: "ag-table-actions-cell"}), render: (_, record) => { if (record.__isSkeleton || isShowingSkeleton) return null return ( - { - if (record.id) onRowClick(record) - }} - onDelete={() => { - if (record.key) { - table.deleteTestcases([String(record.key)]) - } - }} - /> +
e.stopPropagation()} + > + { + if (record.id) onRowClick(record) + }} + onDelete={() => { + if (record.key) { + table.deleteTestcases([String(record.key)]) + } + }} + /> +
) }, }, diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/columns/createStandardColumns.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/columns/createStandardColumns.tsx index 94a6420f9c..fc9c997b31 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/columns/createStandardColumns.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/columns/createStandardColumns.tsx @@ -244,6 +244,7 @@ function createActionsColumn( columnVisibilityLocked: true, // Exclude actions column from CSV export exportEnabled: false, + onCell: () => ({className: "ag-table-actions-cell"}), render: (_, record) => { if (record.__isSkeleton) return null @@ -342,7 +343,10 @@ function createActionsColumn( } return ( -
+
e.stopPropagation()} + > ({ expandable, tableRef, typeChips, + disableInteractiveClickGuard = false, }: InfiniteVirtualTableInnerProps) => { const generatedScopeId = useId() const resolvedScopeId = useMemo( @@ -612,7 +614,16 @@ const InfiniteVirtualTableInnerBase = ({ } : {} - const allProps = { + const rawOnClick = mergeHandlers(baseProps?.onClick, selectionProps?.onClick) + const onClick = + !disableInteractiveClickGuard && rawOnClick + ? (event: MouseEvent) => { + if (shouldIgnoreRowClick(event)) return + rawOnClick(event as MouseEvent) + } + : rawOnClick + + return { ...baseProps, ...shortcutProps, ...selectionProps, @@ -622,23 +633,37 @@ const InfiniteVirtualTableInnerBase = ({ selectionProps?.className, ), onMouseEnter: mergeHandlers(baseProps?.onMouseEnter, shortcutProps?.onMouseEnter), - onClick: mergeHandlers(baseProps?.onClick, selectionProps?.onClick), + onClick, } - - return allProps }, - [finalTableProps.onRow, getShortcutRowProps, selectOnRowClick, handleSelectionRowClick], + [ + finalTableProps.onRow, + getShortcutRowProps, + selectOnRowClick, + handleSelectionRowClick, + disableInteractiveClickGuard, + ], ) const tablePropsWithShortcuts = useMemo>(() => { - if (!getShortcutRowProps && !selectOnRowClick) { + const needsMerge = + getShortcutRowProps || + selectOnRowClick || + (Boolean(finalTableProps.onRow) && !disableInteractiveClickGuard) + if (!needsMerge) { return finalTableProps } return { ...finalTableProps, onRow: mergedOnRow, } - }, [finalTableProps, getShortcutRowProps, selectOnRowClick, mergedOnRow]) + }, [ + finalTableProps, + getShortcutRowProps, + selectOnRowClick, + mergedOnRow, + disableInteractiveClickGuard, + ]) const tableRowSelection = useTableRowSelection(rowSelection) diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx index 82aafa7963..bd40285b86 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx @@ -27,27 +27,18 @@ import useTableExport from "./useTableExport" /** Stable no-op atom used when no external search atom is provided (hooks can't be conditional) */ const dummySearchAtom = atom("") +const INTERACTIVE_SELECTOR = + "button, a, input, textarea, select, [role='button'], [role='menuitem'], [role='checkbox'], " + + ".ant-btn, .ant-checkbox, .ant-checkbox-input, .ant-checkbox-inner, .ant-checkbox-wrapper, " + + ".ant-select, .ant-dropdown-trigger, .ant-table-selection-column, .ag-table-actions-cell" + /** - * Helper to detect if a click event should be ignored for row navigation - * Returns true if the click was on an interactive element (button, link, dropdown, etc.) + * Returns true when the click originated from an interactive element (button, link, + * dropdown, checkbox, etc.) and should not bubble up to the row navigation handler. */ export const shouldIgnoreRowClick = (event: MouseEvent): boolean => { - const target = event.target as HTMLElement - - // Check if clicking on interactive elements - if ( - target.closest("button") || - target.closest("a") || - target.closest(".ant-dropdown-trigger") || - target.closest(".ant-checkbox-wrapper") || - target.closest(".ant-select") || - target.closest("input") || - target.closest("textarea") - ) { - return true - } - - return false + const target = event.target as HTMLElement | null + return Boolean(target?.closest(INTERACTIVE_SELECTOR)) } /** Configuration for built-in search. When provided, the hook manages search state internally. */ diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/types.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/types.ts index e8b91069d0..bbba2f5cc2 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/types.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/types.ts @@ -357,6 +357,12 @@ export interface InfiniteVirtualTableProps resizableColumns?: boolean | ResizableColumnsConfig columnVisibility?: ColumnVisibilityConfig + /** + * When true, disables the built-in guard that prevents row-click navigation + * from firing when the click originates from an interactive element (button, + * checkbox, dropdown, etc.). Defaults to false — the guard is on by default. + */ + disableInteractiveClickGuard?: boolean onColumnToggle?: (payload: { scopeId: string | null columnKey: string