mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +00:00
feat: added context menu
This commit is contained in:
parent
32b087d2ae
commit
b23820f392
@ -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<IServiceName>();
|
||||
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 (
|
||||
<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 = {
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
@ -82,28 +137,45 @@ export function QueryTable({
|
||||
}, [newDataSource, onTableSearch, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="query-table">
|
||||
{isDownloadEnabled && (
|
||||
<div className="query-table--download">
|
||||
<Download
|
||||
data={downloadableData}
|
||||
fileName={`${fileName}-${servicename}`}
|
||||
isLoading={loading as boolean}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ResizeTable
|
||||
columns={tableColumns}
|
||||
tableLayout="fixed"
|
||||
dataSource={filterTable === null ? newDataSource : filterTable}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={paginationConfig}
|
||||
widgetId={widgetId}
|
||||
shouldPersistColumnWidths
|
||||
sticky={sticky}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
<>
|
||||
<div className="query-table">
|
||||
{isDownloadEnabled && (
|
||||
<div className="query-table--download">
|
||||
<Download
|
||||
data={downloadableData}
|
||||
fileName={`${fileName}-${servicename}`}
|
||||
isLoading={loading as boolean}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ResizeTable
|
||||
columns={columnsWithClickHandlers}
|
||||
tableLayout="fixed"
|
||||
dataSource={filterTable === null ? newDataSource : filterTable}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={paginationConfig}
|
||||
widgetId={widgetId}
|
||||
shouldPersistColumnWidths
|
||||
sticky={sticky}
|
||||
// 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}
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
95
frontend/src/container/QueryTable/contextConfig.tsx
Normal file
95
frontend/src/container/QueryTable/contextConfig.tsx
Normal 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 {};
|
||||
}
|
||||
66
frontend/src/container/QueryTable/useTableContextMenu.tsx
Normal file
66
frontend/src/container/QueryTable/useTableContextMenu.tsx
Normal 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;
|
||||
82
frontend/src/periscope/components/ContextMenu/index.tsx
Normal file
82
frontend/src/periscope/components/ContextMenu/index.tsx
Normal 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;
|
||||
@ -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;
|
||||
Loading…
x
Reference in New Issue
Block a user