chore: add in-table-search for metric name and type in metrics explorer (#7356)

This commit is contained in:
Amlan Kumar Nandy 2025-03-19 13:27:39 +05:30 committed by GitHub
parent a64908e571
commit 097e4ca948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 422 additions and 7 deletions

View File

@ -2,7 +2,6 @@ import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
export interface MetricsListFilterValuesPayload {
filterAttributeKeyDataType: string;
@ -14,7 +13,7 @@ export interface MetricsListFilterValuesPayload {
export interface MetricsListFilterValuesResponse {
status: string;
data: {
FilterValues: BaseAutocompleteData[];
filterValues: string[];
};
}

View File

@ -0,0 +1,232 @@
import {
Button,
Empty,
Input,
InputRef,
Menu,
MenuRef,
Popover,
Spin,
} from 'antd';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Search } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
function MetricNameSearch(): JSX.Element {
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [searchString, setSearchString] = useState<string>('');
const [debouncedSearchString, setDebouncedSearchString] = useState<string>('');
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const menuRef = useRef<MenuRef | null>(null);
const inputRef = useRef<InputRef | null>(null);
useEffect(() => {
if (isPopoverOpen) {
setTimeout(() => {
inputRef.current?.focus();
}, 0); // Ensures focus happens after popover opens
setHighlightedIndex(-1);
}
}, [isPopoverOpen]);
const {
data: metricNameFilterValuesData,
isLoading: isLoadingMetricNameFilterValues,
isError: isErrorMetricNameFilterValues,
} = useGetMetricsListFilterValues(
{
searchText: debouncedSearchString,
filterKey: 'metric_name',
filterAttributeKeyDataType: DataTypes.String,
limit: 10,
},
{
enabled: isPopoverOpen,
refetchOnWindowFocus: false,
queryKey: [
REACT_QUERY_KEY.GET_METRICS_LIST_FILTER_VALUES,
'metric_name',
debouncedSearchString,
isPopoverOpen,
],
},
);
const handleSelect = useCallback(
(selectedMetricName: string): void => {
handleChangeQueryData('filters', {
items: [
{
id: 'metric_name',
op: 'CONTAINS',
key: {
id: 'metric_name',
key: 'metric_name',
type: 'tag',
},
value: selectedMetricName,
},
],
op: 'AND',
});
setIsPopoverOpen(false);
},
[handleChangeQueryData],
);
const metricNameFilterValues = useMemo(
() => metricNameFilterValuesData?.payload?.data?.filterValues || [],
[metricNameFilterValuesData],
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (!isPopoverOpen) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex((prev) => {
const nextIndex = prev < metricNameFilterValues.length - 1 ? prev + 1 : 0;
menuRef.current?.focus();
return nextIndex;
});
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightedIndex((prev) => {
const prevIndex = prev > 0 ? prev - 1 : metricNameFilterValues.length - 1;
menuRef.current?.focus();
return prevIndex;
});
} else if (event.key === 'Enter') {
event.preventDefault();
// If there is a highlighted item, select it
if (highlightedIndex >= 0 && metricNameFilterValues[highlightedIndex]) {
handleSelect(metricNameFilterValues[highlightedIndex]);
} else if (highlightedIndex === -1 && searchString) {
// If there is no highlighted item and there is a search string, select the search string
handleSelect(searchString);
}
}
},
[
isPopoverOpen,
highlightedIndex,
metricNameFilterValues,
searchString,
handleSelect,
],
);
const popoverItems = useMemo(() => {
const items: JSX.Element[] = [];
if (searchString) {
items.push(
<Menu.Item
key={searchString}
onClick={(): void => handleSelect(searchString)}
className={highlightedIndex === 0 ? 'highlighted' : ''}
>
{searchString}
</Menu.Item>,
);
}
if (isLoadingMetricNameFilterValues) {
items.push(<Spin />);
} else if (isErrorMetricNameFilterValues) {
items.push(<Empty description="Error fetching metric names" />);
} else if (metricNameFilterValues?.length === 0) {
items.push(<Empty description="No metric names found" />);
} else {
items.push(
...metricNameFilterValues.map((filterValue, index) => (
<Menu.Item
key={filterValue}
onClick={(): void => handleSelect(filterValue)}
className={highlightedIndex === index ? 'highlighted' : ''}
>
{filterValue}
</Menu.Item>
)),
);
}
return items;
}, [
handleSelect,
highlightedIndex,
isErrorMetricNameFilterValues,
isLoadingMetricNameFilterValues,
metricNameFilterValues,
searchString,
]);
const debouncedUpdate = useDebouncedFn((value) => {
setDebouncedSearchString(value as string);
}, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setSearchString(value);
debouncedUpdate(value);
},
[debouncedUpdate],
);
const popoverContent = useMemo(
() => (
<div className="metric-name-search-popover">
<Input
ref={inputRef}
onKeyDown={handleKeyDown}
placeholder="Search..."
value={searchString}
onChange={handleInputChange}
bordered
/>
<Menu ref={menuRef} className="metric-name-search-popover-menu">
{popoverItems}
</Menu>
</div>
),
[handleKeyDown, searchString, handleInputChange, popoverItems],
);
useEffect(() => {
if (!isPopoverOpen) {
setSearchString('');
setDebouncedSearchString('');
}
}, [isPopoverOpen]);
return (
<Popover
content={popoverContent}
trigger="click"
open={isPopoverOpen}
onOpenChange={(val): void => setIsPopoverOpen(val)}
>
<Button
type="text"
shape="circle"
onClick={(e): void => {
e.stopPropagation();
}}
icon={<Search size={14} />}
/>
</Popover>
);
}
export default MetricNameSearch;

View File

@ -0,0 +1,92 @@
import { Button, Menu, Popover, Tooltip } from 'antd';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Search } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { METRIC_TYPE_LABEL_MAP, METRIC_TYPE_VALUES_MAP } from './constants';
function MetricTypeSearch(): JSX.Element {
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const menuItems = useMemo(
() => [
{
key: 'all',
value: 'All',
},
...Object.keys(METRIC_TYPE_LABEL_MAP).map((key) => ({
key: METRIC_TYPE_VALUES_MAP[key as MetricType],
value: METRIC_TYPE_LABEL_MAP[key as MetricType],
})),
],
[],
);
const handleSelect = useCallback(
(selectedMetricType: string): void => {
if (selectedMetricType !== 'all') {
handleChangeQueryData('filters', {
items: [
{
id: 'metric_type',
op: '=',
key: {
id: 'metric_type',
key: 'metric_type',
type: 'tag',
},
value: selectedMetricType,
},
],
op: 'AND',
});
} else {
handleChangeQueryData('filters', {
items: currentQuery.builder.queryData[0].filters.items.filter(
(item) => item.id !== 'metric_type',
),
op: 'AND',
});
}
setIsPopoverOpen(false);
},
[currentQuery.builder.queryData, handleChangeQueryData],
);
const menu = (
<Menu>
{menuItems.map((menuItem) => (
<Menu.Item
key={menuItem.key}
onClick={(): void => handleSelect(menuItem.key)}
>
{menuItem.value}
</Menu.Item>
))}
</Menu>
);
return (
<Popover
content={menu}
trigger="click"
open={isPopoverOpen}
onOpenChange={(val): void => setIsPopoverOpen(val)}
>
<Tooltip title="Filter by metric type">
<Button type="text" shape="circle" icon={<Search size={14} />} />
</Tooltip>
</Popover>
);
}
export default MetricTypeSearch;

View File

@ -80,6 +80,22 @@
background: var(--bg-ink-400);
}
.metric-name-column-header,
.metric-type-column-header {
display: flex;
justify-content: space-between;
.metric-name-column-header-text,
.metric-type-column-header-text {
line-height: 32px;
}
.ant-btn {
margin-left: 4px;
padding: 0;
}
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
@ -263,3 +279,26 @@
padding: 5px 10px;
align-self: flex-end;
}
.ant-popover-inner {
padding: 0 !important;
}
.metric-name-search-popover {
.metric-name-search-popover-menu {
height: 200px;
width: 300px;
overflow-y: scroll;
}
.ant-menu {
.ant-spin,
.ant-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 16px;
}
}
}

View File

@ -20,15 +20,21 @@ import {
import { useMemo } from 'react';
import { METRIC_TYPE_LABEL_MAP } from './constants';
import MetricNameSearch from './MetricNameSearch';
import MetricTypeSearch from './MetricTypeSearch';
import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types';
export const metricsTableColumns: ColumnType<MetricsListItemRowData>[] = [
{
title: <div className="metric-name-column-header">METRIC</div>,
title: (
<div className="metric-name-column-header">
<span className="metric-name-column-header-text">METRIC</span>
<MetricNameSearch />
</div>
),
dataIndex: 'metric_name',
width: 400,
sorter: true,
className: 'metric-name-column-header',
sorter: false,
render: (value: string): React.ReactNode => (
<div className="metric-name-column-value">{value}</div>
),
@ -44,9 +50,14 @@ export const metricsTableColumns: ColumnType<MetricsListItemRowData>[] = [
),
},
{
title: 'TYPE',
title: (
<div className="metric-type-column-header">
<span className="metric-type-column-header-text">TYPE</span>
<MetricTypeSearch />
</div>
),
dataIndex: 'metric_type',
sorter: true,
sorter: false,
width: 150,
},
{

View File

@ -0,0 +1,42 @@
import {
getMetricsListFilterValues,
MetricsListFilterValuesPayload,
MetricsListFilterValuesResponse,
} from 'api/metricsExplorer/getMetricsListFilterValues';
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetMetricsListFilterValues = (
payload: MetricsListFilterValuesPayload,
options?: UseQueryOptions<
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse,
Error
>;
export const useGetMetricsListFilterValues: UseGetMetricsListFilterValues = (
props,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
return [props];
}, [options?.queryKey, props]);
return useQuery<
SuccessResponse<MetricsListFilterValuesResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) => getMetricsListFilterValues(props, signal, headers),
...options,
queryKey,
});
};