Compare commits

..

1 Commits

Author SHA1 Message Date
Ashwin Bhatkal
de6fbfeb2d fix(dashboard): keep ALL on a dynamic variable that holds a value outside its list 2026-10-19 13:02:38 +05:30
11 changed files with 121 additions and 58 deletions

View File

@@ -25,7 +25,6 @@ export const REACT_QUERY_KEY = {
ALERT_RULES_CHART_PREVIEW: 'ALERT_RULES_CHART_PREVIEW',
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',

View File

@@ -2,7 +2,6 @@ import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
@@ -122,26 +121,22 @@ describe('ListAlertRules — actions menu', () => {
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
it('clone action: sends POST to clone endpoint and opens the cloned rule returned by the API', async () => {
let capturedRuleId: string | null = null;
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
let capturedPostBody: unknown = null;
server.use(
rest.post(
'http://localhost/api/v2/rules/:id/clone',
async (req, res, ctx) => {
capturedRuleId = req.params.id as string;
return res(
ctx.status(201),
ctx.json({
data: {
...alertRulesFixture[0],
id: 'cloned-from-server',
alert: 'High CPU Alert - Copy',
},
status: 'success',
}),
);
},
),
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
capturedPostBody = await req.json();
return res(
ctx.status(201),
ctx.json({
data: {
...(capturedPostBody as Record<string, unknown>),
id: 'cloned-from-server',
},
status: 'success',
}),
);
}),
);
renderListAlertRules();
@@ -150,7 +145,9 @@ describe('ListAlertRules — actions menu', () => {
await clickMenuItem('Clone');
await waitFor(() => {
expect(capturedRuleId).toBe('rule-1');
expect(capturedPostBody).toStrictEqual(
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
);
});
// The id from the server response round-trips into the navigate URL — this

View File

@@ -6,10 +6,10 @@ import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
invalidateListRules,
patchRuleByID,
cloneRuleByID,
} from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
@@ -62,9 +62,10 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
const handleClone = useCallback((): void => {
alertActionLogEvent(ALERT_ACTIONS.CLONE, rule);
toast.promise(
cloneRuleByID({
id: rule.id,
}).then(async (response) => {
createRule({
...rule,
alert: `${rule.alert} - Copy`,
} as RuletypesPostableRuleDTO).then(async (response) => {
await invalidateListRules(queryClient);
const newRule = response.data;
if (newRule) {

View File

@@ -7,8 +7,8 @@ import { Switch } from '@signozhq/ui/switch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
import {
useAlertRuleClone,
useAlertRuleDelete,
useAlertRuleDuplicate,
useAlertRuleStatusToggle,
useAlertRuleUpdate,
} from 'pages/AlertDetails/hooks';
@@ -38,7 +38,9 @@ function AlertActionButtons({
const isDarkMode = useIsDarkMode();
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
const { handleAlertClone } = useAlertRuleClone({ ruleId });
const { handleAlertDuplicate } = useAlertRuleDuplicate({
alertDetails: alertDetails as unknown as AlertDef,
});
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
alertDetails: alertDetails as unknown as AlertDef,
@@ -70,10 +72,10 @@ function AlertActionButtons({
menuItems.push(
{
type: 'item',
value: 'clone-rule',
label: 'Clone',
value: 'duplicate-rule',
label: 'Duplicate',
prefix: <Copy size={16} color={Color.BG_VANILLA_400} />,
onClick: handleAlertClone,
onClick: handleAlertDuplicate,
},
{
type: 'item',

View File

@@ -6,7 +6,7 @@ import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
cloneRuleByID,
createRule,
deleteRuleByID,
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
@@ -17,6 +17,7 @@ import {
useGetRuleHistoryOverallStatus,
useGetRuleHistoryStats,
useGetRuleHistoryTopContributors,
useListRules,
} from 'api/generated/services/rules';
import {
Querybuildertypesv5OrderDirectionDTO,
@@ -477,33 +478,37 @@ export const useAlertRuleStatusToggle = ({
return { handleAlertStateToggle };
};
export const useAlertRuleClone = ({
ruleId,
export const useAlertRuleDuplicate = ({
alertDetails,
}: {
ruleId: string;
alertDetails: AlertDef;
}): {
handleAlertClone: () => void;
handleAlertDuplicate: () => void;
} => {
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const params = useUrlQuery();
const { refetch } = useListRules({
query: { cacheTime: 0 },
});
const { showErrorModal } = useErrorModal();
const { mutate: cloneAlert } = useMutation(
[REACT_QUERY_KEY.CLONE_ALERT_RULE],
() => cloneRuleByID({ id: ruleId }),
const { mutate: duplicateAlert } = useMutation(
[REACT_QUERY_KEY.DUPLICATE_ALERT_RULE],
(args: { data: AlertDef }) =>
createRule(toPostableRuleDTOFromAlertDef(args.data)),
{
onSuccess: async (response) => {
onSuccess: async () => {
notifications.success({
message: `Success`,
});
await invalidateListRules(queryClient);
const { data: allAlertsData } = await refetch();
const clonedRule = response?.data;
if (clonedRule?.id) {
params.set(QueryParams.ruleId, String(clonedRule.id));
const rules = allAlertsData?.data;
if (rules && rules.length > 0) {
const clonedAlert = rules[rules.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}
},
@@ -514,15 +519,15 @@ export const useAlertRuleClone = ({
},
);
const handleAlertClone = (): void => {
cloneAlert();
const handleAlertDuplicate = (): void => {
const args = {
data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') },
};
duplicateAlert(args);
};
return { handleAlertClone };
return { handleAlertDuplicate };
};
// Kept for backwards compatibility
export const useAlertRuleDuplicate = useAlertRuleClone;
export const useAlertRuleUpdate = ({
alertDetails,
setAlertRuleName,

View File

@@ -193,6 +193,40 @@ describe('ValueSelector', () => {
expect(onSearch).toHaveBeenLastCalledWith('pay');
});
it('commits ALL even when the selection holds a value outside the list', async () => {
// A search pick, or one an older time window offered: ALL must still go out as
// `__all__`, not as a list that filters the panels to the truncated options.
const onChange = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(
<ValueSelector
options={OPTIONS}
variableType="dynamic"
multiSelect
showAllOption
selection={{ value: ['searched'], allSelected: false }}
onChange={onChange}
emptyFallback={{ value: null, allSelected: true }}
testId="variable-select-env"
dynamic={{
values: OPTIONS,
relatedValues: [],
complete: false,
onSearch: jest.fn(),
onSearchReset: jest.fn(),
}}
/>,
);
await openDropdown();
await user.click(screen.getByText('ALL'));
await user.keyboard('{Escape}');
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ allSelected: true }),
);
});
});
describe('clearing', () => {

View File

@@ -12,6 +12,7 @@ function commit(
values,
options: OPTIONS,
showAllOption: true,
isDynamic: false,
emptyFallback: FALLBACK,
...overrides,
});
@@ -60,6 +61,20 @@ describe('selectionFromCommittedValues', () => {
});
});
// A dynamic ALL is sent as `__all__`, so reading it as a list would filter the
// panels down to the truncated option set.
it('reads every option PLUS a value outside the list as ALL for a dynamic variable', () => {
expect(
commit([...OPTIONS, 'searched'], { isDynamic: true }).allSelected,
).toBe(true);
});
it('does not read a dynamic subset PLUS a value outside the list as ALL', () => {
expect(
commit(['checkout', 'searched'], { isDynamic: true }).allSelected,
).toBe(false);
});
// Derived from the values + options at commit time, never from the old selection.
it('recomputes the marker: a typed value the data now offers is a normal pick', () => {
expect(

View File

@@ -102,6 +102,7 @@ function ValueSelector({
values,
options,
showAllOption,
isDynamic: !!dynamic,
emptyFallback,
});

View File

@@ -51,6 +51,7 @@ interface CommittedValues {
values: string[];
options: string[];
showAllOption: boolean;
isDynamic: boolean;
emptyFallback: VariableSelection;
}
@@ -62,6 +63,7 @@ export function selectionFromCommittedValues({
values,
options,
showAllOption,
isDynamic,
emptyFallback,
}: CommittedValues): VariableSelection {
if (values.length === 0) {
@@ -71,10 +73,12 @@ export function selectionFromCommittedValues({
const customValues = values.filter((value) => !options.includes(value));
// ALL re-materializes to the option set, so a set carrying a typed value is not ALL
// — the next refetch would expand it back and drop what the user typed.
// A dynamic ALL travels as `__all__` and is never re-materialized, so a value outside
// its truncated list (a search pick, an older window's pick) loses nothing.
const allSelected =
showAllOption &&
options.length > 0 &&
customValues.length === 0 &&
(isDynamic || customValues.length === 0) &&
options.every((option) => values.includes(option));
return {

View File

@@ -1,4 +1,3 @@
import * as Sentry from '@sentry/react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -8,10 +7,6 @@ import { DataSource } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
jest.mock('@sentry/react', () => ({
captureEvent: jest.fn(),
}));
jest.mock('utils/aggregationConverter', () => ({
getParsedAggregationOptionsForOrderBy: jest.fn(),
}));
@@ -122,7 +117,6 @@ describe('sanitizeOrderByForExplorer', () => {
const result = sanitizeOrderByForExplorer(query);
expect(result).toStrictEqual([]);
expect(Sentry.captureEvent).not.toHaveBeenCalled();
});
it('handles missing orderBy by returning an empty array', () => {

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import {
IBuilderQuery,
OrderByPayload,
@@ -19,5 +20,15 @@ export function sanitizeOrderByForExplorer(
const current = query.orderBy || [];
const hasInvalidOrderBy = current.some((o) => !allowed.has(o.columnName));
if (hasInvalidOrderBy) {
Sentry.captureEvent({
message: `Invalid orderBy: current: ${JSON.stringify(
current,
)} - allowed: ${JSON.stringify(Array.from(allowed))}`,
level: 'warning',
});
}
return current.filter((o) => allowed.has(o.columnName));
}