feat: added context menu

This commit is contained in:
Aditya Singh 2025-06-27 02:07:23 +05:30
parent 32b087d2ae
commit b23820f392
5 changed files with 436 additions and 22 deletions

View File

@ -7,10 +7,12 @@ import {
createTableColumnsFromQuery, createTableColumnsFromQuery,
RowData, RowData,
} from 'lib/query/createTableColumnsFromQuery'; } from 'lib/query/createTableColumnsFromQuery';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { QueryTableProps } from './QueryTable.intefaces'; import { QueryTableProps } from './QueryTable.intefaces';
import useTableContextMenu from './useTableContextMenu';
import { createDownloadableData } from './utils'; import { createDownloadableData } from './utils';
export function QueryTable({ export function QueryTable({
@ -31,6 +33,21 @@ export function QueryTable({
const { servicename: encodedServiceName } = useParams<IServiceName>(); const { servicename: encodedServiceName } = useParams<IServiceName>();
const servicename = decodeURIComponent(encodedServiceName); const servicename = decodeURIComponent(encodedServiceName);
const { loading } = props; const { loading } = props;
const {
coordinates,
popoverPosition,
clickedData,
onClose,
onClick,
} = useCoordinates();
const { menuItemsConfig } = useTableContextMenu({
widgetId: widgetId || '',
clickedData,
onClose,
coordinates,
});
const { columns: newColumns, dataSource: newDataSource } = useMemo(() => { const { columns: newColumns, dataSource: newDataSource } = useMemo(() => {
if (columns && dataSource) { if (columns && dataSource) {
return { columns, dataSource }; return { columns, dataSource };
@ -54,6 +71,44 @@ export function QueryTable({
const tableColumns = modifyColumns ? modifyColumns(newColumns) : newColumns; 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 (
<div
// have its dimension equal to the column width
onClick={(e): void => {
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}
</div>
);
},
})),
[tableColumns, onClick],
);
const paginationConfig = { const paginationConfig = {
pageSize: 10, pageSize: 10,
showSizeChanger: false, showSizeChanger: false,
@ -82,28 +137,45 @@ export function QueryTable({
}, [newDataSource, onTableSearch, searchTerm]); }, [newDataSource, onTableSearch, searchTerm]);
return ( return (
<div className="query-table"> <>
{isDownloadEnabled && ( <div className="query-table">
<div className="query-table--download"> {isDownloadEnabled && (
<Download <div className="query-table--download">
data={downloadableData} <Download
fileName={`${fileName}-${servicename}`} data={downloadableData}
isLoading={loading as boolean} fileName={`${fileName}-${servicename}`}
/> isLoading={loading as boolean}
</div> />
)} </div>
<ResizeTable )}
columns={tableColumns} <ResizeTable
tableLayout="fixed" columns={columnsWithClickHandlers}
dataSource={filterTable === null ? newDataSource : filterTable} tableLayout="fixed"
scroll={{ x: 'max-content' }} dataSource={filterTable === null ? newDataSource : filterTable}
pagination={paginationConfig} scroll={{ x: 'max-content' }}
widgetId={widgetId} pagination={paginationConfig}
shouldPersistColumnWidths widgetId={widgetId}
sticky={sticky} shouldPersistColumnWidths
// eslint-disable-next-line react/jsx-props-no-spreading sticky={sticky}
{...props} // eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
</div>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header}
items={menuItemsConfig.items}
onClose={onClose}
/> />
</div>
{/* <ContextMenuV2
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header}
items={menuItemsConfig.items}
onClose={onClose}
/> */}
</>
); );
} }

View File

@ -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<RowData>;
}
interface ColumnClickData {
record: RowData;
column: ColumnType<RowData>;
}
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: (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 16px',
cursor: 'pointer',
}}
onClick={(): void =>
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}
>
<span style={{ color: '#3B5AFB', fontSize: 18 }}>=</span>
<span style={{ fontWeight: 600, color: '#2B2B43' }}>Is this</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 16px',
cursor: 'pointer',
}}
onClick={(): void =>
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}
>
<span style={{ color: '#3B5AFB', fontSize: 18 }}></span>
<span style={{ fontWeight: 600, color: '#2B2B43' }}>Is not this</span>
</div>
</>
),
};
}
return {};
}

View File

@ -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<RowData>;
}
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;

View File

@ -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 (
<Popover
content={items}
title={title}
open={Boolean(coordinates)}
onOpenChange={(open: boolean): void => {
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 */}
<span
style={{
position: 'fixed',
left: position.left,
top: position.top,
width: 0,
height: 0,
}}
/>
</Popover>
);
}
// default props
ContextMenu.defaultProps = {
popoverPosition: null,
title: '',
items: null,
children: null,
};
export default ContextMenu;

View File

@ -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<RowData>;
}
// 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<ClickedData | null>(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;