Add cancel query functionality to dashboard edit panel (#10152)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled

* feat: add cancel query functionality for dashboard panels

* feat: pass loading query from edit panel

* feat: revert loading
This commit is contained in:
Aditya Singh
2026-02-02 20:32:42 +05:30
committed by GitHub
parent 518dfcbe59
commit 30a6721472
5 changed files with 124 additions and 27 deletions

View File

@@ -1,4 +1,7 @@
.dashboard-navigation { .dashboard-navigation {
.run-query-dashboard-btn {
min-width: 180px;
}
.ant-tabs-tab { .ant-tabs-tab {
border: none !important; border: none !important;
margin-left: 0px !important; margin-left: 0px !important;

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { QueryKey } from 'react-query';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Button, Tabs, Typography } from 'antd'; import { Button, Tabs, Typography } from 'antd';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
@@ -35,8 +36,11 @@ import ClickHouseQueryContainer from './QueryBuilder/clickHouse';
import PromQLQueryContainer from './QueryBuilder/promQL'; import PromQLQueryContainer from './QueryBuilder/promQL';
import './QuerySection.styles.scss'; import './QuerySection.styles.scss';
function QuerySection({
function QuerySection({ selectedGraph }: QueryProps): JSX.Element { selectedGraph,
queryRangeKey,
isLoadingQueries,
}: QueryProps): JSX.Element {
const { const {
currentQuery, currentQuery,
handleRunQuery: handleRunQueryFromQueryBuilder, handleRunQuery: handleRunQueryFromQueryBuilder,
@@ -237,7 +241,13 @@ function QuerySection({ selectedGraph }: QueryProps): JSX.Element {
tabBarExtraContent={ tabBarExtraContent={
<span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}> <span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" /> <TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
<RunQueryBtn label="Stage & Run Query" onStageRunQuery={handleRunQuery} /> <RunQueryBtn
className="run-query-dashboard-btn"
label="Stage & Run Query"
onStageRunQuery={handleRunQuery}
isLoadingQueries={isLoadingQueries}
queryRangeKey={queryRangeKey}
/>
</span> </span>
} }
items={items} items={items}
@@ -248,6 +258,8 @@ function QuerySection({ selectedGraph }: QueryProps): JSX.Element {
interface QueryProps { interface QueryProps {
selectedGraph: PANEL_TYPES; selectedGraph: PANEL_TYPES;
queryRangeKey?: QueryKey;
isLoadingQueries?: boolean;
} }
export default QuerySection; export default QuerySection;

View File

@@ -1,4 +1,5 @@
import { memo, useEffect } from 'react'; import { memo, useEffect } from 'react';
import { useMemo } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { ENTITY_VERSION_V5 } from 'constants/app'; import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -24,8 +25,8 @@ function LeftContainer({
setSelectedTracesFields, setSelectedTracesFields,
selectedWidget, selectedWidget,
requestData, requestData,
setRequestData,
isLoadingPanelData, isLoadingPanelData,
setRequestData,
setQueryResponse, setQueryResponse,
enableDrillDown = false, enableDrillDown = false,
}: WidgetGraphProps): JSX.Element { }: WidgetGraphProps): JSX.Element {
@@ -35,15 +36,20 @@ function LeftContainer({
AppState, AppState,
GlobalReducer GlobalReducer
>((state) => state.globalTime); >((state) => state.globalTime);
const queryResponse = useGetQueryRange(requestData, ENTITY_VERSION_V5, { const queryRangeKey = useMemo(
enabled: !!stagedQuery, () => [
queryKey: [
REACT_QUERY_KEY.GET_QUERY_RANGE, REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedInterval, globalSelectedInterval,
requestData, requestData,
minTime, minTime,
maxTime, maxTime,
], ],
[globalSelectedInterval, requestData, minTime, maxTime],
);
const queryResponse = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
enabled: !!stagedQuery,
queryKey: queryRangeKey,
keepPreviousData: true,
}); });
// Update parent component with query response for legend colors // Update parent component with query response for legend colors
@@ -64,7 +70,11 @@ function LeftContainer({
enableDrillDown={enableDrillDown} enableDrillDown={enableDrillDown}
/> />
<QueryContainer className="query-section-left-container"> <QueryContainer className="query-section-left-container">
<QuerySection selectedGraph={selectedGraph} /> <QuerySection
selectedGraph={selectedGraph}
queryRangeKey={queryRangeKey}
isLoadingQueries={queryResponse.isFetching}
/>
{selectedGraph === PANEL_TYPES.LIST && ( {selectedGraph === PANEL_TYPES.LIST && (
<ExplorerColumnsRenderer <ExplorerColumnsRenderer
selectedLogFields={selectedLogFields} selectedLogFields={selectedLogFields}

View File

@@ -1,4 +1,7 @@
import { useCallback } from 'react';
import { QueryKey, useIsFetching, useQueryClient } from 'react-query';
import { Button } from 'antd'; import { Button } from 'antd';
import cx from 'classnames';
import { import {
ChevronUp, ChevronUp,
Command, Command,
@@ -9,35 +12,56 @@ import {
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS'; import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import './RunQueryBtn.scss'; import './RunQueryBtn.scss';
interface RunQueryBtnProps { interface RunQueryBtnProps {
className?: string;
label?: string; label?: string;
isLoadingQueries?: boolean; isLoadingQueries?: boolean;
handleCancelQuery?: () => void; handleCancelQuery?: () => void;
onStageRunQuery?: () => void; onStageRunQuery?: () => void;
queryRangeKey?: QueryKey;
} }
function RunQueryBtn({ function RunQueryBtn({
className,
label, label,
isLoadingQueries, isLoadingQueries,
handleCancelQuery, handleCancelQuery,
onStageRunQuery, onStageRunQuery,
queryRangeKey,
}: RunQueryBtnProps): JSX.Element { }: RunQueryBtnProps): JSX.Element {
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS; const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
return isLoadingQueries ? ( const queryClient = useQueryClient();
const isKeyFetchingCount = useIsFetching(
queryRangeKey as QueryKey | undefined,
);
const isLoading =
typeof isLoadingQueries === 'boolean'
? isLoadingQueries
: isKeyFetchingCount > 0;
const onCancel = useCallback(() => {
if (handleCancelQuery) {
return handleCancelQuery();
}
if (queryRangeKey) {
queryClient.cancelQueries(queryRangeKey);
}
}, [handleCancelQuery, queryClient, queryRangeKey]);
return isLoading ? (
<Button <Button
type="default" type="default"
icon={<Loader2 size={14} className="loading-icon animate-spin" />} icon={<Loader2 size={14} className="loading-icon animate-spin" />}
className="cancel-query-btn periscope-btn danger" className={cx('cancel-query-btn periscope-btn danger', className)}
onClick={handleCancelQuery} onClick={onCancel}
> >
Cancel Cancel
</Button> </Button>
) : ( ) : (
<Button <Button
type="primary" type="primary"
className="run-query-btn periscope-btn primary" className={cx('run-query-btn periscope-btn primary', className)}
disabled={isLoadingQueries || !onStageRunQuery} disabled={isLoading || !onStageRunQuery}
onClick={onStageRunQuery} onClick={onStageRunQuery}
icon={<Play size={14} />} icon={<Play size={14} />}
> >

View File

@@ -3,6 +3,16 @@ import { fireEvent, render, screen } from '@testing-library/react';
import RunQueryBtn from '../RunQueryBtn'; import RunQueryBtn from '../RunQueryBtn';
jest.mock('react-query', () => {
const actual = jest.requireActual('react-query');
return {
...actual,
useIsFetching: jest.fn(),
useQueryClient: jest.fn(),
};
});
import { useIsFetching, useQueryClient } from 'react-query';
// Mock OS util // Mock OS util
jest.mock('utils/getUserOS', () => ({ jest.mock('utils/getUserOS', () => ({
getUserOperatingSystem: jest.fn(), getUserOperatingSystem: jest.fn(),
@@ -11,10 +21,43 @@ jest.mock('utils/getUserOS', () => ({
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS'; import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
describe('RunQueryBtn', () => { describe('RunQueryBtn', () => {
test('renders run state and triggers on click', () => { beforeEach(() => {
jest.resetAllMocks();
(getUserOperatingSystem as jest.Mock).mockReturnValue( (getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS, UserOperatingSystem.MACOS,
); );
(useIsFetching as jest.Mock).mockReturnValue(0);
(useQueryClient as jest.Mock).mockReturnValue({
cancelQueries: jest.fn(),
});
});
test('uses isLoadingQueries prop over useIsFetching', () => {
// Simulate fetching but prop forces not loading
(useIsFetching as jest.Mock).mockReturnValue(1);
const onRun = jest.fn();
render(<RunQueryBtn onStageRunQuery={onRun} isLoadingQueries={false} />);
// Should show "Run Query" (not cancel)
const runBtn = screen.getByRole('button', { name: /run query/i });
expect(runBtn).toBeInTheDocument();
expect(runBtn).toBeEnabled();
});
test('fallback cancel: uses handleCancelQuery when no key provided', () => {
(useIsFetching as jest.Mock).mockReturnValue(0);
const cancelQueries = jest.fn();
(useQueryClient as jest.Mock).mockReturnValue({ cancelQueries });
const onCancel = jest.fn();
render(<RunQueryBtn isLoadingQueries handleCancelQuery={onCancel} />);
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelBtn);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(cancelQueries).not.toHaveBeenCalled();
});
test('renders run state and triggers on click', () => {
const onRun = jest.fn(); const onRun = jest.fn();
render(<RunQueryBtn onStageRunQuery={onRun} />); render(<RunQueryBtn onStageRunQuery={onRun} />);
const btn = screen.getByRole('button', { name: /run query/i }); const btn = screen.getByRole('button', { name: /run query/i });
@@ -24,17 +67,11 @@ describe('RunQueryBtn', () => {
}); });
test('disabled when onStageRunQuery is undefined', () => { test('disabled when onStageRunQuery is undefined', () => {
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS,
);
render(<RunQueryBtn />); render(<RunQueryBtn />);
expect(screen.getByRole('button', { name: /run query/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /run query/i })).toBeDisabled();
}); });
test('shows cancel state and calls handleCancelQuery', () => { test('shows cancel state and calls handleCancelQuery', () => {
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS,
);
const onCancel = jest.fn(); const onCancel = jest.fn();
render(<RunQueryBtn isLoadingQueries handleCancelQuery={onCancel} />); render(<RunQueryBtn isLoadingQueries handleCancelQuery={onCancel} />);
const cancel = screen.getByRole('button', { name: /cancel/i }); const cancel = screen.getByRole('button', { name: /cancel/i });
@@ -42,10 +79,24 @@ describe('RunQueryBtn', () => {
expect(onCancel).toHaveBeenCalledTimes(1); expect(onCancel).toHaveBeenCalledTimes(1);
}); });
test('derives loading from queryKey via useIsFetching and cancels via queryClient', () => {
(useIsFetching as jest.Mock).mockReturnValue(1);
const cancelQueries = jest.fn();
(useQueryClient as jest.Mock).mockReturnValue({ cancelQueries });
const queryKey = ['GET_QUERY_RANGE', '1h', { some: 'req' }, 1, 2];
render(<RunQueryBtn queryRangeKey={queryKey} />);
// Button switches to cancel state
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
expect(cancelBtn).toBeInTheDocument();
// Clicking cancel calls cancelQueries with the key
fireEvent.click(cancelBtn);
expect(cancelQueries).toHaveBeenCalledWith(queryKey);
});
test('shows Command + CornerDownLeft on mac', () => { test('shows Command + CornerDownLeft on mac', () => {
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS,
);
const { container } = render( const { container } = render(
<RunQueryBtn onStageRunQuery={(): void => {}} />, <RunQueryBtn onStageRunQuery={(): void => {}} />,
); );
@@ -70,9 +121,6 @@ describe('RunQueryBtn', () => {
}); });
test('renders custom label when provided', () => { test('renders custom label when provided', () => {
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS,
);
const onRun = jest.fn(); const onRun = jest.fn();
render(<RunQueryBtn onStageRunQuery={onRun} label="Stage & Run Query" />); render(<RunQueryBtn onStageRunQuery={onRun} label="Stage & Run Query" />);
expect( expect(