Compare commits

...

1 Commits

Author SHA1 Message Date
Aditya Singh
375b3f8305 feat(query-builder): move group-by and order-by key suggestions to the v5 fields API (#13127)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- The group-by and order-by filters in the query builder still fetched
key suggestions from the v3 `attribute_keys` API. Every explorer, panel
editor and alert form that is otherwise on v5 called it through them.
- Both filters now read `GET /api/v1/fields/keys` through the existing
`useFieldKeysSuggestion` hook. The dropdown rows, badges, chips and the
stored group-by shape are unchanged.
- `container/ExplorerOrderBy` had no importer and is removed.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/158
Part of https://github.com/SigNoz/events-pod/issues/156


### ScreenRecordings



https://github.com/user-attachments/assets/fa907fa3-e6f0-45b6-b171-82e212ccda29



https://github.com/user-attachments/assets/0f6f246a-786e-42c2-b528-4949785ca7e1



https://github.com/user-attachments/assets/933d7495-23af-4f2f-8aca-07693149f85a





#### Additional Information

- A new group-by pick stores `type` from the v5 field context
(`attribute` / `resource`). v3 wrote `tag`. Saved dashboards, alerts and
views are not rewritten.
2026-10-14 19:52:18 +00:00
17 changed files with 388 additions and 283 deletions

View File

@@ -18,13 +18,11 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
index,
version,
panelType,
signalSource = '',
}: {
query: IBuilderQuery;
index: number;
version: string;
panelType: PANEL_TYPES | null;
signalSource: string;
}): JSX.Element {
const { setAggregationOptions } = useQueryBuilderV2Context();
const {
@@ -206,7 +204,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
disabled={!queryAggregation.metricName}
query={query}
onChange={handleChangeGroupByKeys}
signalSource={signalSource}
/>
</div>
</div>
@@ -243,7 +240,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
disabled={!queryAggregation.metricName}
query={query}
onChange={handleChangeGroupByKeys}
signalSource={signalSource}
/>
</div>
</div>

View File

@@ -316,7 +316,6 @@ export const QueryV2 = forwardRef(function QueryV2(
index={index}
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
version="v4"
signalSource={signalSource as 'meter' | ''}
/>
)}

View File

@@ -1,73 +0,0 @@
import { memo, useMemo } from 'react';
import { Select, Spin } from 'antd';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { StringOperators } from 'types/common/queryBuilder';
function ExplorerOrderBy({ query, onChange }: OrderByFilterProps): JSX.Element {
const {
debouncedSearchText,
selectedValue,
aggregationOptions,
generateOptions,
createOptions,
handleChange,
handleSearchKeys,
} = useOrderByFilter({ query, onChange });
const { data, isFetching } = useGetAggregateKeys(
{
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText: debouncedSearchText,
},
{
keepPreviousData: true,
},
);
const options = useMemo(() => {
const keysOptions = createOptions(data?.payload?.attributeKeys || []);
const customOptions = createOptions([
{ key: 'timestamp', type: '', dataType: DataTypes.EMPTY },
]);
const baseOptions = [
...customOptions,
...(query.aggregateOperator === StringOperators.NOOP
? []
: aggregationOptions),
...keysOptions,
];
return generateOptions(baseOptions);
}, [
aggregationOptions,
createOptions,
data?.payload?.attributeKeys,
generateOptions,
query.aggregateOperator,
]);
return (
<Select
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}
showSearch
showArrow={false}
value={selectedValue}
labelInValue
options={options}
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
/>
);
}
export default memo(ExplorerOrderBy);

View File

@@ -1,18 +1,5 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
// Total, not Partial, so a signal added to the generated enum has to be mapped here
// before it compiles.
const SIGNAL_TO_DATA_SOURCE: Record<
TelemetrytypesSignalDTO,
DataSource | undefined
> = {
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
// The "unset" member: not a data source a query can be built against.
[TelemetrytypesSignalDTO['']]: undefined,
};
import { DataSource, SIGNAL_TO_DATA_SOURCE } from 'types/common/queryBuilder';
export function signalsToDataSources(
signals: readonly TelemetrytypesSignalDTO[],

View File

@@ -5,5 +5,4 @@ export type GroupByFilterProps = {
query: IBuilderQuery;
onChange: (values: BaseAutocompleteData[]) => void;
disabled: boolean;
signalSource?: string;
};

View File

@@ -6,39 +6,37 @@ import {
useMemo,
useState,
} from 'react';
import { useQueryClient } from 'react-query';
import { Select, Spin } from 'antd';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
// ** Constants
import { idDivider, QueryBuilderKeys } from 'constants/queryBuilder';
import { idDivider } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import useDebounce from 'hooks/useDebounce';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
// ** Components
// ** Helpers
import { isEqual, uniqWith } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import {
DATA_SOURCE_TO_SIGNAL,
QUERY_SOURCE_TO_FIELD_SOURCE,
} from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { getMetricNameForQuery } from '../utils';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { GroupByFilterProps } from './GroupByFilter.interfaces';
import { toAttributeKey } from './utils';
export const GroupByFilter = memo(function GroupByFilter({
query,
onChange,
disabled,
signalSource,
}: GroupByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState<string>('');
const [optionsData, setOptionsData] = useState<
SelectOption<string, ReactNode>[]
>([]);
const [localValues, setLocalValues] = useState<SelectOption<string, string>[]>(
[],
);
@@ -46,74 +44,40 @@ export const GroupByFilter = memo(function GroupByFilter({
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
const dataSource = useMemo(() => {
if (signalSource === 'meter') {
return 'meter' as DataSource;
}
return query.dataSource;
}, [signalSource, query.dataSource]);
const { isFetching } = useGetAggregateKeys(
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
{
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource,
aggregateOperator: query.aggregateOperator || '',
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
metricName: getMetricNameForQuery(query),
searchText: debouncedValue,
},
{
queryKey: [debouncedValue, isFocused],
enabled: !disabled && isFocused,
onSuccess: (data) => {
const keys = query.groupBy.reduce<string[]>((acc, item) => {
acc.push(item.key);
return acc;
}, []);
const filteredOptions: BaseAutocompleteData[] =
data?.payload?.attributeKeys?.filter(
(attrKey) => !keys.includes(attrKey.key),
) || [];
const options: SelectOption<string, ReactNode>[] =
filteredOptions.map((item) => ({
label: (
<OptionRenderer
key={item.key}
label={item.key}
value={item.key}
dataType={item.dataType || ''}
type={item.type || ''}
/>
),
value: `${item.id}`,
})) || [];
setOptionsData(options);
},
},
query.builderQueryType,
{ enabled: !disabled && isFocused, keepPreviousData: true },
);
const getAttributeKeys = useCallback(async () => {
const response = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, searchText, isFocused],
async () =>
getAggregateKeys({
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText,
}),
);
const attributeKeys = useMemo(
() => (fieldKeys ?? []).map(toAttributeKey),
[fieldKeys],
);
return response.payload?.attributeKeys || [];
}, [
isFocused,
query.aggregateAttribute?.key,
query.aggregateOperator,
dataSource,
queryClient,
searchText,
]);
const optionsData = useMemo((): SelectOption<string, ReactNode>[] => {
const selectedKeys = new Set(query.groupBy.map((item) => item.key));
return attributeKeys
.filter((item) => !selectedKeys.has(item.key))
.map((item) => ({
label: (
<OptionRenderer
key={item.key}
label={item.key}
value={item.key}
dataType={item.dataType || ''}
type={item.type || ''}
/>
),
value: `${item.id}`,
}));
}, [attributeKeys, query.groupBy]);
const handleSearchKeys = (searchText: string): void => {
setSearchText(searchText);
@@ -129,15 +93,13 @@ export const GroupByFilter = memo(function GroupByFilter({
};
const handleChange = useCallback(
async (values: SelectOption<string, string>[]): Promise<void> => {
const keys = await getAttributeKeys();
(values: SelectOption<string, string>[]): void => {
const groupByValues: BaseAutocompleteData[] = values.map((item) => {
const id = item.value;
const currentValue = item.value.split(idDivider)[0];
if (id && id.includes(idDivider)) {
const attribute = keys.find((item) => item.id === id);
const attribute = attributeKeys.find((item) => item.id === id);
const existAttribute = query.groupBy.find((item) => item.id === id);
if (attribute) {
@@ -149,14 +111,14 @@ export const GroupByFilter = memo(function GroupByFilter({
}
}
return chooseAutocompleteFromCustomValue(keys, currentValue);
return chooseAutocompleteFromCustomValue(attributeKeys, currentValue);
});
const result = uniqWith(groupByValues, isEqual);
onChange(result);
},
[getAttributeKeys, onChange, query.groupBy],
[attributeKeys, onChange, query.groupBy],
);
const clearSearch = useCallback(() => {

View File

@@ -9,137 +9,140 @@ import { IBuilderQuery } from '../../../../../types/api/queryBuilder/queryBuilde
import { DataSource } from '../../../../../types/common/queryBuilder';
import { GroupByFilter } from '../GroupByFilter';
const BASE_URL = ENVIRONMENT.baseURL;
const attributeKeysURL = `${BASE_URL}/api/v3/autocomplete/attribute_keys`;
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
function setup(
overrides?: Partial<React.ComponentProps<typeof GroupByFilter>>,
): { onChange: jest.Mock } {
type FieldKeyVariant = {
name: string;
fieldContext: string;
fieldDataType?: string;
};
const fieldKeysBody = (
variants: FieldKeyVariant[],
): Record<string, unknown> => ({
status: 'success',
data: {
complete: true,
keys: variants.reduce<Record<string, FieldKeyVariant[]>>(
(acc, variant) => ({
...acc,
[variant.name]: [...(acc[variant.name] ?? []), variant],
}),
{},
),
},
});
const groupByInput = (): HTMLInputElement =>
screen.getByTestId('group-by').querySelector('input') as HTMLInputElement;
function setup(queryOverrides?: Partial<IBuilderQuery>): {
onChange: jest.Mock;
} {
const onChange = jest.fn();
const query = {
dataSource: DataSource.METRICS,
aggregateAttribute: { key: 'service.name' },
aggregateAttribute: { key: 'signoz_calls_total' },
aggregateOperator: 'COUNT',
groupBy: [],
...queryOverrides,
} as unknown as IBuilderQuery;
render(
<GroupByFilter
query={query}
onChange={onChange}
disabled={false}
signalSource="meter"
{...overrides}
/>,
);
render(<GroupByFilter query={query} onChange={onChange} disabled={false} />);
return { onChange };
}
// wait for dropdown and select the option instead of hitting Enter
async function clickOption(label: string): Promise<void> {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const option = await screen.findByText(
(content, el) =>
content.trim() === label && !!el?.closest('.ant-select-item-option'),
);
await user.click(option);
}
describe('GroupByFilter', () => {
const dataSourceCalls: string[] = [];
let callCount = 0;
const requests: URLSearchParams[] = [];
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
dataSourceCalls.length = 0;
callCount = 0;
requests.length = 0;
jest.clearAllMocks();
});
afterAll(() => {
server.close();
});
it('uses meter datasource for suggestions and normalization', async () => {
function useFieldKeys(variants: FieldKeyVariant[]): void {
server.use(
rest.get(attributeKeysURL, (req, res, ctx) => {
const ds = req.url.searchParams.get('dataSource') || '';
dataSourceCalls.push(ds);
callCount += 1;
const attributeKeys =
callCount === 1
? [
{
id: 'service.name--string--',
key: 'service.name',
dataType: 'string',
type: '',
},
]
: [];
return res(
ctx.status(200),
ctx.json({
data: { attributeKeys },
}),
);
rest.get(fieldKeysURL, (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(ctx.status(200), ctx.json(fieldKeysBody(variants)));
}),
);
}
const { onChange } = setup();
it('asks the fields API for meter keys and emits a typed custom key', async () => {
useFieldKeys([]);
const { onChange } = setup({ source: 'meter' });
const select = screen.getByTestId('group-by');
fireEvent.focus(select);
fireEvent.focus(screen.getByTestId('group-by'));
await waitFor(() => expect(dataSourceCalls.length).toBeGreaterThanOrEqual(1));
expect(dataSourceCalls[0]).toBe('meter');
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
expect(requests[0].get('signal')).toBe('metrics');
expect(requests[0].get('source')).toBe('meter');
expect(requests[0].get('metricName')).toBe('signoz_calls_total');
const input = select.querySelector('input') as HTMLInputElement;
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.type(input, 'custom.attr');
// wait for dropdown and select the option instead of hitting Enter
await screen.findByRole('listbox');
const option = await screen.findByRole('option', { name: 'custom.attr' });
await user.click(option);
await user.type(groupByInput(), 'custom.attr');
await clickOption('custom.attr');
expect(onChange).toHaveBeenCalled();
expect(dataSourceCalls[dataSourceCalls.length - 1]).toBe('meter');
const emitted = onChange.mock.calls[0][0][0];
expect(emitted.key).toBe('custom.attr');
expect(emitted.id).toBeTruthy();
expect(emitted.id).not.toBe('----');
});
it('clicks suggested option and emits proper value', async () => {
server.use(
rest.get(attributeKeysURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
attributeKeys: [
{
id: 'service.name--string--',
key: 'service.name',
dataType: 'string',
type: '',
},
],
},
}),
),
),
);
it('emits the picked suggestion with its context and data type', async () => {
useFieldKeys([
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
]);
const { onChange } = setup();
const combo = screen.getByRole('combobox');
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(combo);
await user.click(groupByInput());
await clickOption('service.name');
await screen.findByRole('listbox');
const option = await screen.findByText(
(content, el) =>
content.trim() === 'service.name' &&
!!el?.closest('.ant-select-item-option'),
);
await user.click(option);
// Expect payload to be resolved to the exact attribute returned by MSW
await waitFor(() => expect(onChange).toHaveBeenCalled());
const emitted = onChange.mock.calls[0][0][0];
expect(emitted.key).toBe('service.name');
expect(emitted.id).toBe('service.name--string--');
// Expect payload to be resolved to the exact attribute returned by MSW
expect(onChange.mock.calls[0][0][0]).toMatchObject({
key: 'service.name',
type: 'resource',
dataType: 'string',
id: 'service.name--string--resource',
});
});
it('offers each context of a name as its own option', async () => {
useFieldKeys([
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
{ name: 'service.name', fieldContext: 'attribute', fieldDataType: 'string' },
]);
setup();
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(groupByInput());
await waitFor(() =>
expect(
screen
.getAllByText('service.name')
.filter((el) => !!el.closest('.ant-select-item-option')),
).toHaveLength(2),
);
});
});

View File

@@ -0,0 +1,24 @@
import {
baseAutoCompleteIdKeysOrder,
initialAutocompleteData,
} from 'constants/queryBuilder';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
export const toAttributeKey = (
key: TelemetryFieldKey,
): BaseAutocompleteData => {
const fields = {
key: key.name,
dataType: fieldDataTypeToDataType(key.fieldDataType),
type: key.fieldContext ?? '',
};
return {
...initialAutocompleteData,
...fields,
id: createIdFromObjectFields(fields, baseAutoCompleteIdKeysOrder),
};
};

View File

@@ -1,11 +1,17 @@
import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
DATA_SOURCE_TO_SIGNAL,
DataSource,
MetricAggregateOperator,
QUERY_SOURCE_TO_FIELD_SOURCE,
} from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { getMetricNameForQuery } from '../utils';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
import { useOrderByFilter } from './useOrderByFilter';
@@ -27,13 +33,14 @@ export function OrderByFilter({
handleSearchKeys,
} = useOrderByFilter({ query, onChange, entityVersion });
const { data, isFetching } = useGetAggregateKeys(
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
{
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
metricName: getMetricNameForQuery(query),
searchText: debouncedSearchText,
},
query.builderQueryType,
{
enabled: !!query.aggregateAttribute?.key || isRawQuery,
keepPreviousData: true,
@@ -47,9 +54,11 @@ export function OrderByFilter({
);
const optionsData = useMemo(() => {
const keyOptions = createOptions(data?.payload?.attributeKeys || []);
const groupByOptions = createOptions(query.groupBy);
const aggregationOptionsFromParsed = createOptions(parsedAggregationOptions);
const keyOptions = createOptions((fieldKeys ?? []).map((key) => key.name));
const groupByOptions = createOptions(query.groupBy.map((item) => item.key));
const aggregationOptionsFromParsed = createOptions(
parsedAggregationOptions.map((item) => item.key),
);
const options =
query.aggregateOperator === MetricAggregateOperator.NOOP
@@ -62,7 +71,7 @@ export function OrderByFilter({
return generateOptions(options);
}, [
createOptions,
data?.payload?.attributeKeys,
fieldKeys,
query.groupBy,
query.aggregateOperator,
parsedAggregationOptions,
@@ -92,6 +101,7 @@ export function OrderByFilter({
options={optionsData}
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
data-testid="order-by"
/>
);
}

View File

@@ -0,0 +1,85 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilter } from '../OrderByFilter';
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
const fieldKeysBody = (names: string[]): Record<string, unknown> => ({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [name, [{ name, fieldContext: 'resource' }]]),
),
},
});
const orderByInput = (): HTMLInputElement =>
screen.getByTestId('order-by').querySelector('input') as HTMLInputElement;
function setup(query: Partial<IBuilderQuery>): { onChange: jest.Mock } {
const onChange = jest.fn();
render(
<OrderByFilter
query={
{
dataSource: DataSource.LOGS,
aggregateOperator: 'noop',
aggregateAttribute: { key: '' },
groupBy: [],
orderBy: [],
aggregations: [{ expression: 'count()' }],
...query,
} as unknown as IBuilderQuery
}
onChange={onChange}
isRawQuery
isNewQueryV2
/>,
);
return { onChange };
}
describe('OrderByFilter', () => {
const requests: URLSearchParams[] = [];
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
requests.length = 0;
jest.clearAllMocks();
});
afterAll(() => {
server.close();
});
it('offers fields API keys in both directions for a raw query', async () => {
server.use(
rest.get(fieldKeysURL, (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(ctx.status(200), ctx.json(fieldKeysBody(['service.name'])));
}),
);
const { onChange } = setup({});
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(orderByInput());
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
expect(requests[0].get('signal')).toBe('logs');
await screen.findByText('service.name desc');
await user.click(await screen.findByText('service.name asc'));
expect(onChange).toHaveBeenCalledWith([
{ columnName: 'service.name', order: 'asc' },
]);
});
});

View File

@@ -4,7 +4,6 @@ import useDebounce from 'hooks/useDebounce';
import { IOption } from 'hooks/useResourceAttribute/types';
import { isEqual, uniqWith } from 'lodash-es';
import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
@@ -25,7 +24,7 @@ export type UseOrderByFilterResult = {
selectedValue: IOption[];
aggregationOptions: IOption[];
generateOptions: (options: IOption[]) => IOption[];
createOptions: (data: BaseAutocompleteData[]) => IOption[];
createOptions: (keys: string[]) => IOption[];
handleChange: (values: IOption[]) => void;
handleSearchKeys: (search: string) => void;
};
@@ -121,7 +120,7 @@ export const useOrderByFilter = ({
};
const createOptions = useCallback(
(data: BaseAutocompleteData[]): IOption[] => mapLabelValuePairs(data).flat(),
(keys: string[]): IOption[] => mapLabelValuePairs(keys).flat(),
[],
);

View File

@@ -1,6 +1,5 @@
import { IOption } from 'hooks/useResourceAttribute/types';
import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
OrderByPayload,
@@ -34,11 +33,8 @@ export const transformToOrderByStringValues = (
return prepareSelectedValue;
};
export function mapLabelValuePairs(
arr: BaseAutocompleteData[],
): Array<IOption>[] {
return arr.map((item) => {
const value = item.key;
export function mapLabelValuePairs(keys: string[]): Array<IOption>[] {
return keys.map((value) => {
return [
{
label: `${value} ${ORDERBY_FILTERS.ASC}`,

View File

@@ -3,7 +3,12 @@ import { HAVING_FILTER_REGEXP } from 'constants/regExp';
import { IOption } from 'hooks/useResourceAttribute/types';
import uniqWith from 'lodash-es/unionWith';
import { parse } from 'papaparse';
import { HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import {
HavingForm,
IBuilderQuery,
} from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { ORDERBY_FILTERS } from './OrderByFilter/config';
import {
@@ -111,3 +116,17 @@ export const filterByOperatorConfig = (
OPERATORS_CONFIG[key].includes(option.label),
);
};
export const getMetricNameForQuery = (
query: IBuilderQuery,
): string | undefined => {
if (query.dataSource !== DataSource.METRICS) {
return undefined;
}
return (
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
undefined
);
};

View File

@@ -43,8 +43,16 @@ export const getFieldKeysQueryOptions = (
refetchOnWindowFocus: false,
});
export type FieldKeysQueryOverrides = Partial<
Omit<FieldKeysQueryOptions, 'queryKey' | 'queryFn' | 'select'>
>;
export const useFieldKeysSuggestion = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
options?: FieldKeysQueryOverrides,
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
useQuery(getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType));
useQuery({
...getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType),
...options,
});

View File

@@ -1,10 +1,61 @@
import type { GetFieldsKeys200 } from 'api/generated/services/sigNoz.schemas';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest } from 'msw';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource, SIGNAL_TO_DATA_SOURCE } from 'types/common/queryBuilder';
import {
autocompleteKeysResponse,
autocompleteValuesResponse,
} from './__story_mockdata__/queryBuilderAutocomplete';
/**
* The v5 keys list, served from the same per-signal fixture as the v3 pair so
* the group-by and order-by filters open on the product's keys. A context
* prefix in `searchText` narrows to that context the way the backend does.
*/
const fieldKeysResponseForSignal = (
signal: string | null,
searchText: string,
): GetFieldsKeys200 => {
const context = Object.values(TelemetrytypesFieldContextDTO).find(
(ctx) => ctx && searchText.toLowerCase().startsWith(`${ctx}.`),
);
const keys = (
autocompleteKeysResponse(
SIGNAL_TO_DATA_SOURCE[signal as TelemetrytypesSignalDTO] ?? DataSource.LOGS,
context ? searchText.slice(context.length + 1) : searchText,
).data.attributeKeys ?? []
).map((attribute) => ({
name: attribute.key,
signal: signal as TelemetrytypesSignalDTO,
fieldContext:
attribute.type === 'resource'
? TelemetrytypesFieldContextDTO.resource
: TelemetrytypesFieldContextDTO.attribute,
fieldDataType: (attribute.dataType === DataTypes.Int64 ||
attribute.dataType === DataTypes.Float64
? TelemetrytypesFieldDataTypeDTO.number
: attribute.dataType) as TelemetrytypesFieldDataTypeDTO,
}));
return {
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
keys
.filter((key) => !context || key.fieldContext === context)
.map((key) => [key.name, [key]]),
),
},
};
};
/**
* The query shapes `src/mocks-server/handlers.ts` answers from its own fixture:
* the APM resource attribute filter, the one case its two autocomplete handlers
@@ -37,6 +88,17 @@ const isSharedFixtureValuesQuery = (params: URLSearchParams): boolean => {
* both. Returning nothing hands the request on to the next matching handler.
*/
export const queryBuilderHandlers = [
rest.get('http://localhost/api/v1/fields/keys', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(
fieldKeysResponseForSignal(
req.url.searchParams.get('signal'),
req.url.searchParams.get('searchText') ?? '',
),
),
),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
(req, res, ctx) => {

View File

@@ -6,6 +6,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
import { Format } from 'constants/formats/types';
import { EQueryType } from 'types/common/dashboard';
import {
BuilderQuerySource,
DataSource,
QueryBuilderData,
ReduceOperators,
@@ -104,7 +105,7 @@ export type IBuilderQuery = {
pageSize?: number;
offset?: number;
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
source?: 'meter' | '';
source?: BuilderQuerySource;
builderQueryType?: BuilderQueryType;
/** Heatmap only. */
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;

View File

@@ -1,5 +1,8 @@
import { Dispatch, SetStateAction } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
TelemetrytypesSignalDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Format } from 'constants/formats/types';
@@ -32,6 +35,31 @@ export const DATA_SOURCE_TO_SIGNAL: Record<
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
};
// Total, not Partial, so a signal added to the generated enum has to be mapped here
// before it compiles.
export const SIGNAL_TO_DATA_SOURCE: Record<
TelemetrytypesSignalDTO,
DataSource | undefined
> = {
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
// The "unset" member: not a data source a query can be built against.
[TelemetrytypesSignalDTO['']]: undefined,
};
export type BuilderQuerySource = `${TelemetrytypesSourceDTO}`;
// `source` tells meter and metrics apart, since both share the metrics signal:
// meter sends 'meter', the rest is ''.
export const QUERY_SOURCE_TO_FIELD_SOURCE: Record<
BuilderQuerySource,
TelemetrytypesSourceDTO | undefined
> = {
meter: TelemetrytypesSourceDTO.meter,
'': undefined,
};
export enum StringOperators {
NOOP = 'noop',
COUNT = 'count',