diff --git a/frontend/src/container/QueryTable/QueryTable.tsx b/frontend/src/container/QueryTable/QueryTable.tsx index 16376eb04059..9e59ff67ec48 100644 --- a/frontend/src/container/QueryTable/QueryTable.tsx +++ b/frontend/src/container/QueryTable/QueryTable.tsx @@ -7,10 +7,12 @@ import { createTableColumnsFromQuery, RowData, } from 'lib/query/createTableColumnsFromQuery'; +import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { QueryTableProps } from './QueryTable.intefaces'; +import useTableContextMenu from './useTableContextMenu'; import { createDownloadableData } from './utils'; export function QueryTable({ @@ -31,6 +33,21 @@ export function QueryTable({ const { servicename: encodedServiceName } = useParams(); const servicename = decodeURIComponent(encodedServiceName); const { loading } = props; + + const { + coordinates, + popoverPosition, + clickedData, + onClose, + onClick, + } = useCoordinates(); + const { menuItemsConfig } = useTableContextMenu({ + widgetId: widgetId || '', + clickedData, + onClose, + coordinates, + }); + const { columns: newColumns, dataSource: newDataSource } = useMemo(() => { if (columns && dataSource) { return { columns, dataSource }; @@ -54,6 +71,44 @@ export function QueryTable({ const tableColumns = modifyColumns ? modifyColumns(newColumns) : newColumns; + // Add click handlers to columns to capture clicked data + const columnsWithClickHandlers = useMemo( + () => + tableColumns.map((column: any): any => ({ + ...column, + render: (text: any, record: RowData, index: number): JSX.Element => { + const originalRender = column.render; + const renderedContent = originalRender + ? originalRender(text, record, index) + : text; + + return ( +
{ + e.stopPropagation(); + console.log('@record:', { record, column }); + onClick(e, { record, column }); + }} + onKeyDown={(e): void => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + onClick(e as any, { record, column }); + } + }} + style={{ cursor: 'pointer' }} + role="button" + tabIndex={0} + > + {renderedContent} +
+ ); + }, + })), + [tableColumns, onClick], + ); + const paginationConfig = { pageSize: 10, showSizeChanger: false, @@ -82,28 +137,45 @@ export function QueryTable({ }, [newDataSource, onTableSearch, searchTerm]); return ( -
- {isDownloadEnabled && ( -
- -
- )} - +
+ {isDownloadEnabled && ( +
+ +
+ )} + +
+ -
+ + {/* */} + ); } diff --git a/frontend/src/container/QueryTable/contextConfig.tsx b/frontend/src/container/QueryTable/contextConfig.tsx new file mode 100644 index 000000000000..8ea0ce99e97b --- /dev/null +++ b/frontend/src/container/QueryTable/contextConfig.tsx @@ -0,0 +1,95 @@ +import { ColumnType } from 'antd/lib/table'; +import { RowData } from 'lib/query/createTableColumnsFromQuery'; +import { ReactNode } from 'react'; + +export type ContextMenuItem = ReactNode; + +interface ClickedData { + record: RowData; + column: ColumnType; +} + +interface ColumnClickData { + record: RowData; + column: ColumnType; +} + +export function getContextMenuConfig( + clickedData: ClickedData | null, + panelType: string, + onColumnClick: (operator: string, data: ColumnClickData) => void, +): { header?: string; items?: ContextMenuItem } { + if ( + panelType === 'table' && + clickedData?.column && + !(clickedData.column as any).queryName + ) { + const columnName = clickedData.column.title || clickedData.column.dataIndex; + return { + header: `Filter by ${columnName}`, + items: ( + <> +
+ onColumnClick('=', { + record: clickedData.record, + column: clickedData.column, + }) + } + onKeyDown={(e): void => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onColumnClick('=', { + record: clickedData.record, + column: clickedData.column, + }); + } + }} + role="button" + tabIndex={0} + > + = + Is this +
+
+ onColumnClick('!=', { + record: clickedData.record, + column: clickedData.column, + }) + } + onKeyDown={(e): void => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onColumnClick('!=', { + record: clickedData.record, + column: clickedData.column, + }); + } + }} + role="button" + tabIndex={0} + > + + Is not this +
+ + ), + }; + } + return {}; +} diff --git a/frontend/src/container/QueryTable/useTableContextMenu.tsx b/frontend/src/container/QueryTable/useTableContextMenu.tsx new file mode 100644 index 000000000000..caf161d068cb --- /dev/null +++ b/frontend/src/container/QueryTable/useTableContextMenu.tsx @@ -0,0 +1,66 @@ +import { ColumnType } from 'antd/lib/table'; +import { QueryParams } from 'constants/query'; +import { useSafeNavigate } from 'hooks/useSafeNavigate'; +import createQueryParams from 'lib/createQueryParams'; +import { RowData } from 'lib/query/createTableColumnsFromQuery'; +import { useCallback, useMemo } from 'react'; +import { useLocation } from 'react-router-dom-v5-compat'; + +import { getContextMenuConfig } from './contextConfig'; + +interface ClickedData { + record: RowData; + column: ColumnType; +} + +interface UseTableContextMenuProps { + widgetId: string; + clickedData: ClickedData | null; + onClose: () => void; + coordinates: { x: number; y: number } | null; +} + +export function useTableContextMenu({ + widgetId, + clickedData, + onClose, + coordinates, +}: UseTableContextMenuProps): { + menuItemsConfig: { header?: string; items?: React.ReactNode }; +} { + const { pathname, search } = useLocation(); + const { safeNavigate } = useSafeNavigate(); + + const onColumnClick = useCallback((): void => { + const queryParams = { + [QueryParams.expandedWidgetId]: widgetId, + }; + const updatedSearch = createQueryParams(queryParams); + const existingSearch = new URLSearchParams(search); + const isExpandedWidgetIdPresent = existingSearch.has( + QueryParams.expandedWidgetId, + ); + if (isExpandedWidgetIdPresent) { + existingSearch.delete(QueryParams.expandedWidgetId); + } + const separator = existingSearch.toString() ? '&' : ''; + const newSearch = `${existingSearch}${separator}${updatedSearch}`; + + safeNavigate({ + pathname, + search: newSearch, + }); + onClose(); + }, [widgetId, search, pathname, safeNavigate, onClose]); + + const menuItemsConfig = useMemo(() => { + if (coordinates) { + return getContextMenuConfig(clickedData, 'table', onColumnClick); + } + return {}; + }, [clickedData, onColumnClick, coordinates]); + + return { menuItemsConfig }; +} + +export default useTableContextMenu; diff --git a/frontend/src/periscope/components/ContextMenu/index.tsx b/frontend/src/periscope/components/ContextMenu/index.tsx new file mode 100644 index 000000000000..c282f9416c28 --- /dev/null +++ b/frontend/src/periscope/components/ContextMenu/index.tsx @@ -0,0 +1,82 @@ +import { Popover, PopoverProps } from 'antd'; +import { ReactNode } from 'react'; + +import { useCoordinates } from './useCoordinates'; + +export { useCoordinates }; + +interface ContextMenuProps { + coordinates: { x: number; y: number } | null; + popoverPosition?: { + left: number; + top: number; + placement: PopoverProps['placement']; + } | null; + title?: string; + items?: ReactNode; + onClose: () => void; + children?: ReactNode; +} + +export function ContextMenu({ + coordinates, + popoverPosition, + title, + items, + onClose, + children, +}: ContextMenuProps): JSX.Element | null { + if (!coordinates || !items) { + return null; + } + + const position = popoverPosition ?? { + left: coordinates.x + 10, + top: coordinates.y - 10, + placement: 'right' as PopoverProps['placement'], + }; + + return ( + { + if (!open) { + onClose(); + } + }} + trigger="click" + overlayStyle={{ + position: 'fixed', + left: position.left, + top: position.top, + width: 180, + maxHeight: 400, + }} + arrow={false} + placement={position.placement} + > + {children} + {/* phantom span to force Popover to position relative to viewport */} + + + ); +} + +// default props +ContextMenu.defaultProps = { + popoverPosition: null, + title: '', + items: null, + children: null, +}; +export default ContextMenu; diff --git a/frontend/src/periscope/components/ContextMenu/useCoordinates.tsx b/frontend/src/periscope/components/ContextMenu/useCoordinates.tsx new file mode 100644 index 000000000000..4eb68daa917c --- /dev/null +++ b/frontend/src/periscope/components/ContextMenu/useCoordinates.tsx @@ -0,0 +1,99 @@ +import { PopoverProps } from 'antd'; +import { ColumnType } from 'antd/lib/table'; +import { RowData } from 'lib/query/createTableColumnsFromQuery'; +import { useCallback, useState } from 'react'; + +interface ClickedData { + record: RowData; + column: ColumnType; +} + +// Custom hook for managing coordinates +export const useCoordinates = (): { + coordinates: { x: number; y: number } | null; + clickedData: ClickedData | null; + popoverPosition: { + left: number; + top: number; + placement: PopoverProps['placement']; + } | null; + onClick: (e: React.MouseEvent, data?: ClickedData) => void; + onClose: () => void; +} => { + const [coordinates, setCoordinates] = useState<{ + x: number; + y: number; + } | null>(null); + const [clickedData, setClickedData] = useState(null); + const [popoverPosition, setPopoverPosition] = useState<{ + left: number; + top: number; + placement: PopoverProps['placement']; + } | null>(null); + + const calculatePosition = useCallback((x: number, y: number): { + left: number; + top: number; + placement: PopoverProps['placement']; + } => { + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const popoverWidth = 300; // Estimated popover width + const popoverHeight = 200; // Estimated popover height + const offset = 10; + + let left = x + offset; + let top = y - offset; + let placement = 'right'; + + // Check if popover would go off the right edge + if (left + popoverWidth > windowWidth) { + left = x - popoverWidth - offset; + placement = 'left'; + } + + // Check if popover would go off the left edge + if (left < 0) { + left = offset; + placement = 'right'; + } + + // Check if popover would go off the top edge + if (top < 0) { + top = y + offset; + placement = placement === 'right' ? 'bottomRight' : 'bottomLeft'; + } + + // Check if popover would go off the bottom edge + if (top + popoverHeight > windowHeight) { + top = y - popoverHeight - offset; + placement = placement === 'right' ? 'topRight' : 'topLeft'; + } + + return { left, top, placement: placement as PopoverProps['placement'] }; + }, []); + + const onClick = useCallback( + (e: React.MouseEvent, data?: ClickedData): void => { + const coords = { x: e.clientX, y: e.clientY }; + const position = calculatePosition(coords.x, coords.y); + + setCoordinates(coords); + setPopoverPosition(position); + if (data) { + setClickedData(data); + } + }, + [calculatePosition], + ); + + const onClose = useCallback((): void => { + setCoordinates(null); + setClickedData(null); + setPopoverPosition(null); + }, []); + + return { coordinates, clickedData, popoverPosition, onClick, onClose }; +}; + +export default useCoordinates;