Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7238,6 +7238,10 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedLogsLink:
type: string
relatedTracesLink:
type: string
ruleId:
type: string
ruleName:
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/api/generated/services/sigNoz.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8320,6 +8320,14 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedLogsLink?: string;
/**
* @type string
*/
relatedTracesLink?: string;
/**
* @type string
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';

import { prepareQueryRangePayloadV5 } from './prepareQueryRangePayloadV5';
import {
convertBuilderQueriesToV5,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';

jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
Expand Down Expand Up @@ -899,3 +902,36 @@ describe('prepareQueryRangePayloadV5', () => {
expect(logSpec.filter).toStrictEqual({ expression: '' });
});
});

describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(
{
A: {
dataSource: DataSource.METRICS,
queryName: 'A',
aggregations: [{ metricName: 'm', spaceAggregation: 'p99' }],
having,
} as unknown as IBuilderQuery,
},
'time_series',
PANEL_TYPES.TIME_SERIES,
);
return envelope.spec as MetricBuilderQuery;
};

it.each([
['a legacy V4 array', []],
['a blank empty-object having', { expression: '' }],
['a whitespace-only having', { expression: ' ' }],
['a nullish having', undefined],
])('drops %s (serializes to undefined)', (_label, having) => {
expect(buildSpec(having).having).toBeUndefined();
});

it('preserves a real having expression', () => {
expect(buildSpec({ expression: 'count() > 5' }).having).toStrictEqual({
expression: 'count() > 5',
});
});
});
27 changes: 17 additions & 10 deletions frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ function getFilter(queryData: IBuilderQuery): Filter {
};
}

/**
* Normalizes a builder query's `having` to the V5 shape, treating "no having filter" as absent.
* V4 stored it as an array; V5 expects `{ expression }`. An array (legacy), a nullish value, or a
* blank expression — the query builder seeds `{ expression: '' }` for an empty having — all mean
* "no having" and must serialize to `undefined`. Emitting an empty `{ expression: '' }` sends a
* no-op filter and, because a saved panel never carries one, reads an untouched panel as dirty.
*/
function normalizeHaving(having: unknown): Having | undefined {
if (having == null || Array.isArray(having)) {
return undefined;
}
const { expression } = having as Having;
return expression?.trim() ? (having as Having) : undefined;
}

function createBaseSpec(
queryData: IBuilderQuery,
requestType: RequestType,
Expand Down Expand Up @@ -181,12 +196,7 @@ function createBaseSpec(
)
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(queryData.having) || Array.isArray(queryData.having)
? undefined
: (queryData?.having as Having),
having: normalizeHaving(queryData.having),
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {
Expand Down Expand Up @@ -414,10 +424,7 @@ function createTraceOperatorBaseSpec(
)
: undefined,
legend: isEmpty(legend) ? undefined : legend,
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(having) || Array.isArray(having) ? undefined : (having as Having),
having: normalizeHaving(having),
selectFields: isEmpty(nonEmptySelectColumns)
? undefined
: nonEmptySelectColumns?.map(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ describe.each([
const callArgs = mockDownloadExportData.mock.calls[0][0];
const query = callArgs.body.compositeQuery.queries[0];
expect(query.spec.groupBy).toBeUndefined();
expect(query.spec.having).toStrictEqual({ expression: '' });
// An empty having ({ expression: '' }) is a no-op filter and serializes to
// undefined — same as the cleared groupBy above.
expect(query.spec.having).toBeUndefined();
});
});

Expand Down
120 changes: 74 additions & 46 deletions frontend/src/components/NewSelect/CustomMultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
Expand All @@ -33,6 +34,7 @@ import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
ALL_SELECTED_VALUE,
filterOptionsBySearch,
findOptionLabelText,
handleScrollToBottom,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
Expand Down Expand Up @@ -1937,7 +1939,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
};

return (
const tag = (
<div
className={cx('ant-select-selection-item', {
'ant-select-selection-item-active': isActive,
Expand Down Expand Up @@ -1967,13 +1969,32 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
)}
</div>
);

// `label` arrives already cut to maxTagTextLength, so the reveal reads the
// option's own text (falling back to the raw value for freeform tags).
return (
<TooltipSimple
side="top"
delayDuration={300}
title={findOptionLabelText(options, value)}
>
{tag}
</TooltipSimple>
);
}

// Fallback for safety, should not be reached
return <div />;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
[
isAllSelected,
activeChipIndex,
selectedChips,
selectedValues,
maxTagCount,
options,
],
);

// Simple onClear handler to prevent clearing ALL
Expand All @@ -1992,51 +2013,58 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({

// ===== Component Rendering =====
return (
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
// Self-provided so the per-tag tooltips work wherever this select is rendered,
// without every consumer having to sit under an app-level provider.
<TooltipProvider>
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx(
'custom-multiselect-dropdown-container',
popupClassName,
)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
</TooltipProvider>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';

import CustomMultiSelect from '../CustomMultiSelect';

const OPTIONS = [
{ label: 'checkout-service-prod', value: 'checkout-service-prod' },
{ label: 'payments-service-prod', value: 'payments-service-prod' },
{ label: 'cart-service-prod', value: 'cart-service-prod' },
];

const SELECTED = ['checkout-service-prod', 'payments-service-prod'];

function renderSelect(): void {
render(
<TooltipProvider>
<CustomMultiSelect
options={OPTIONS}
value={SELECTED}
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
/>
</TooltipProvider>,
);
}

/** Hovers an element and lets the tooltip's open delay elapse. */
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(element);
act(() => {
jest.advanceTimersByTime(500);
});
}

describe('CustomMultiSelect tag tooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it("reveals a tag's untruncated value on hover", async () => {
renderSelect();

await hover(screen.getByText('checkout-s...'));

expect(screen.getByRole('tooltip')).toHaveTextContent(
'checkout-service-prod',
);
});

// The `+N` placeholder stays the caller's to render — several callers already wrap
// it in a tooltip of their own, and a second one would stack on top.
it('leaves the +N overflow placeholder untouched', async () => {
renderSelect();

await hover(screen.getByText('+1'));

expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
15 changes: 15 additions & 0 deletions frontend/src/components/NewSelect/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ export const prioritizeOrAddOptionForMultiSelect = (
return [...flatOutSelectedOptions, ...filteredOptions];
};

export const findOptionLabelText = (
options: OptionData[],
value: string,
): string => {
const match = options
.flatMap((option) =>
'options' in option && Array.isArray(option.options)
? option.options
: [option],
)
.find((option) => option.value === value);

return typeof match?.label === 'string' ? match.label : value;
};

/**
* Filters options based on search text
*/
Expand Down
Loading
Loading