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,
|
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}
|
||||||
|
/> */}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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