Compare commits

..

4 Commits

Author SHA1 Message Date
Abhi Kumar
5082ee4263 fix(dashboards-v2): anchor panel editor dirty check to saved panel; retain query edits on refresh
The editor re-derived its dirty baseline from the incoming panel prop, which
is seeded from transient state — a stale URL compositeQuery after a refresh or
the View-mode handoff spec — instead of the persisted panel. So the draft was
compared against an already-edited baseline: after a refresh the panel showed
the saved query yet read dirty (inverted), and a View->Edit query change read
clean with Save disabled.

- Thread a savedPanel baseline (existingPanel) through PanelEditorPage ->
  PanelEditorContainer -> usePanelEditSession, distinct from the seed panel.
- usePanelEditorDraft compares isSpecDirty against savedPanel; the draft still
  seeds from the (possibly handed-off) panel.
- usePanelEditorQuerySync computes isQueryDirty as a V5-envelope comparison of
  the live query against the saved queries, routing both sides through the same
  fromPerses->toPerses round-trip so builder-added defaults absent from an older
  stored query never read an untouched panel as modified. Replaces the racy
  captured-first-stagedQuery baseline.
- Drop the mount forceReset on useShareBuilderUrl (both its reasons are now
  moot) so a refresh / browser Back-Forward hydrates the edited query from the
  URL and the staged-query effect syncs it into the draft — query edits survive
  a refresh instead of reverting to the saved query.

Add real-QueryBuilderProvider integration tests covering untouched-not-dirty
(incl. an older/minimal stored query), refresh retention, and handoff-dirty;
update the draft + query-sync unit tests.
2026-07-17 16:56:00 +05:30
Abhi Kumar
3eb94d9979 fix(dashboards-v2): stop query-builder dropdowns being clipped in the panel editor
The panel editor renders the query builder inside an overflow:hidden resizable pane, so
antd Select popups (group-by, order-by, having, metric name, aggregator, units) were cut
off. Make the shared QB filters defer to an ancestor ConfigProvider's popup container
(falling back to trigger.parentNode) via useSelectPopupContainer, and wrap the editor's
builder in a ConfigProvider that portals popups to document.body. Scoped to the editor
host, so the View modal keeps its own container and other surfaces are unchanged.
2026-07-17 16:56:00 +05:30
Abhi Kumar
66ec99648e fix(dashboards-v2): sync panel editor preview to the staged query on browser back/forward
The query builder reverts both currentQuery and stagedQuery via initQueryBuilderData on a
URL re-stage (chiefly browser Back/Forward), but the preview kept the last Run's result.
Commit the staged query into the draft whenever it re-stages outside an explicit Run, so the
preview follows. Live edits touch only currentQuery, so they still wait for Run; commitQuery
no-ops when unchanged.
2026-07-17 16:56:00 +05:30
Abhi Kumar
1eb3d0318c fix(dashboards-v2): carry active time window across panel editor navigation
Opening, creating, or leaving the V2 panel editor now preserves the selected time
range (relative or custom) via URL params derived from Redux global time, so a
custom range picked in the editor isn't reset to the dashboard default on return.

Adds timeParamsFromGlobalTime + useTimeSearchParams; useOpenPanelEditor now takes an
options object ({ handoffState, search }) and appends the time params, and
useCreatePanel routes through it.
2026-07-17 16:56:00 +05:30
28 changed files with 831 additions and 133 deletions

View File

@@ -22,7 +22,7 @@ import {
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -39,6 +39,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
signalSource,
setAttributeKeys,
}: AgregatorFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
@@ -289,7 +290,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
return (
<AutoComplete
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
placeholder={getPlaceholder()}
style={selectStyle}
filterOption={false}

View File

@@ -2,7 +2,7 @@ import { Select, SelectProps, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { getCategorySelectOptionByName } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { categoryToSupport } from './config';
import { selectStyles } from './styles';
@@ -13,6 +13,7 @@ function BuilderUnitsFilter({
onChange,
yAxisUnit,
}: IBuilderUnitsFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { currentQuery, handleOnUnitsChange } = useQueryBuilder();
const selectedValue = yAxisUnit || currentQuery?.unit;
@@ -36,7 +37,7 @@ function BuilderUnitsFilter({
Y-axis unit
</Typography.Text>
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
style={selectStyles}
onChange={onChangeHandler}
value={selectedValue}

View File

@@ -9,12 +9,13 @@ import {
} from 'lib/query/transformQueryBuilderData';
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { SelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../../utils';
import { HavingFilterProps, HavingTagRenderProps } from './types';
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = formula;
const [searchText, setSearchText] = useState<string>('');
const [localValues, setLocalValues] = useState<string[]>([]);
@@ -171,7 +172,7 @@ function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
return (
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { OrderByProps } from './types';
@@ -13,6 +13,7 @@ function OrderByFilter({
onChange,
query,
}: OrderByProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
createOptions,
@@ -64,7 +65,7 @@ function OrderByFilter({
return (
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -21,7 +21,7 @@ import { isEqual, uniqWith } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -33,6 +33,7 @@ export const GroupByFilter = memo(function GroupByFilter({
disabled,
signalSource,
}: GroupByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState<string>('');
const [optionsData, setOptionsData] = useState<
@@ -174,7 +175,7 @@ export const GroupByFilter = memo(function GroupByFilter({
return (
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -16,7 +16,7 @@ import {
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../utils';
// ** Types
@@ -27,6 +27,7 @@ export function HavingFilter({
query,
onChange,
}: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = query;
const [searchText, setSearchText] = useState<string>('');
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
@@ -231,7 +232,7 @@ export function HavingFilter({
return (
<>
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -14,7 +14,7 @@ import {
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -85,6 +85,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
signalSource,
'data-testid': dataTestId,
}: MetricNameSelectorProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
@@ -272,7 +273,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}

View File

@@ -3,7 +3,7 @@ import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
@@ -16,6 +16,7 @@ export function OrderByFilter({
entityVersion,
isNewQueryV2 = false,
}: OrderByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
selectedValue,
@@ -78,7 +79,7 @@ export function OrderByFilter({
return (
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -50,7 +50,7 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../constants/features';
@@ -95,6 +95,7 @@ function QueryBuilderSearch({
disableNavigationShortcuts,
entity,
}: QueryBuilderSearchProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,
@@ -397,7 +398,7 @@ function QueryBuilderSearch({
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
transitionName=""
choiceTransitionName=""
virtual={false}

View File

@@ -50,7 +50,7 @@ import {
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from '../QueryBuilderSearch/config';
@@ -157,6 +157,8 @@ function QueryBuilderSearchV2(
selectProps,
} = props;
const getPopupContainer = useSelectPopupContainer();
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { handleRunQuery, currentQuery } = useQueryBuilder();
@@ -989,7 +991,7 @@ function QueryBuilderSearchV2(
{...selectProps}
data-testid={'qb-search-select'}
ref={selectRef}
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
{...(hasPopupContainer ? { getPopupContainer } : {})}
{...(maxTagCount ? { maxTagCount } : {})}
key={queryTags.join('.')}
virtual={false}

View File

@@ -73,6 +73,25 @@ describe('usePanelEditorDraft', () => {
expect(result.current.isSpecDirty).toBe(false);
});
it('flags spec-dirty when the seed differs from the saved baseline (View handoff)', () => {
// The editor opens on a handed-off, already-edited spec (`seed`) but compares
// against the persisted panel (`saved`) — so it starts dirty, not clean.
const seed = panel('Memory', 'usage');
const saved = panel('CPU', 'usage');
const { result } = renderHook(() => usePanelEditorDraft(seed, saved));
expect(result.current.isSpecDirty).toBe(true);
});
it('is clean when the seed matches the saved baseline', () => {
const { result } = renderHook(() =>
usePanelEditorDraft(panel('CPU', 'usage'), panel('CPU', 'usage')),
);
expect(result.current.isSpecDirty).toBe(false);
});
it('reset restores the spec and clears dirty after an edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));

View File

@@ -0,0 +1,201 @@
import { renderHook, waitFor } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { AllTheProviders } from 'tests/test-utils';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
// Exercises the REAL query builder provider (not mocks) so the dirty check is
// verified against the builder's actual re-serialization — the "always dirty"
// regression only reproduces with the real normalization in the loop.
const panelType = PANEL_TYPES.TIME_SERIES;
function makeSavedQueries(): DashboardtypesQueryDTO[] {
const base: Query = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu',
},
],
},
};
return toPerses(base, panelType);
}
function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
describe('usePanelEditorQuerySync (real query builder)', () => {
it('an untouched existing panel is NOT query-dirty on mount', async () => {
const saved = makeSavedQueries();
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(saved),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper: AllTheProviders },
);
// The builder force-resets to the saved query asynchronously; once settled the
// live query must serialize back to the saved queries → clean.
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
// And stays clean (no late re-stage flips it dirty).
expect(result.current.isQueryDirty).toBe(false);
});
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
// An older saved query carries only a few fields; the builder re-emits many more
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read
// this as always-dirty; the round-tripped baseline normalizes both sides.
const minimalSaved: DashboardtypesQueryDTO[] = [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
aggregations: [
{ metricName: 'system_cpu_time', timeAggregation: 'avg' },
],
},
},
],
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(minimalSaved),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: minimalSaved,
}),
{ wrapper: AllTheProviders },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
expect(result.current.isQueryDirty).toBe(false);
});
it('retains an in-editor query edit carried in the URL across a refresh (and reads dirty)', async () => {
// Simulate a refresh mid-edit: the saved panel is unchanged, but the URL still
// carries the last-run (edited) query. The builder must hydrate from the URL —
// not discard it — so the edit survives, and it must read dirty against saved.
const saved = makeSavedQueries();
const editedInUrl: Query = {
...initialQueriesMap[DataSource.METRICS],
id: 'edited-in-url',
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu-edited-in-url',
},
],
},
};
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(editedInUrl)),
);
const setSpec = jest.fn();
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<AllTheProviders initialRoute={`/?${params.toString()}`}>
{children}
</AllTheProviders>
);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
// The draft/preview open on the saved query…
draft: makePanel(saved),
panelType,
setSpec,
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper },
);
// The URL edit is retained → dirty, and it's synced into the draft so the
// preview follows (setSpec called with the edited query).
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
expect(setSpec).toHaveBeenCalled();
});
it('is query-dirty when the draft carries an edit the saved panel does not (View handoff)', async () => {
const saved = makeSavedQueries();
const editedBase: Query = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu-edited',
},
],
},
};
const editedQueries = toPerses(editedBase, panelType);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
// The builder seeds from the draft (the handed-off edit)…
draft: makePanel(editedQueries),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
// …but the baseline is the persisted panel.
savedQueries: saved,
}),
{ wrapper: AllTheProviders },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
});
});

View File

@@ -95,6 +95,7 @@ describe('usePanelEditorQuerySync', () => {
draft?: DashboardtypesPanelDTO;
setSpec?: jest.Mock;
refetch?: jest.Mock;
savedQueries?: DashboardtypesPanelSpecDTO['queries'];
} = {},
): {
result: {
@@ -119,20 +120,22 @@ describe('usePanelEditorQuerySync', () => {
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
refetch,
savedQueries: opts.savedQueries,
}),
);
return { result, setSpec, refetch, rerender };
}
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
it('seeds the builder from the draft queries on mount (URL query, when present, wins)', () => {
setup();
expect(mockFromPerses).toHaveBeenCalledWith(
SAVED_QUERIES,
PANEL_TYPES.TIME_SERIES,
);
// No forceReset: useShareBuilderUrl resets to the seed only when the URL carries
// no query, so an in-editor edit in the URL survives a refresh.
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
defaultValue: SEED_V1,
forceReset: true,
});
});
@@ -342,44 +345,127 @@ describe('usePanelEditorQuerySync', () => {
});
});
describe('query dirty + save', () => {
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
describe('staged-query re-sync (browser back/forward)', () => {
it('commits the staged query into the draft when it re-stages', () => {
const state = builderState();
mockUseQueryBuilder.mockImplementation(() => state);
// Baseline is the builder's own normalized staged query — immune to the
// raw-seed vs builder-normalized serialization drift.
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
expect.anything(),
STAGED_V1,
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Browser Back re-stages a different query via initQueryBuilderData; the
// preview must follow it instead of keeping the last Run's result.
mockGetIsQueryModified.mockReturnValue(true);
state.stagedQuery = {
id: 'restaged',
queryType: 'builder',
} as unknown as Query;
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when only the live query changes (no re-stage)', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Live edit: currentQuery changes, staged query + structure unchanged.
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('query dirty + save', () => {
// isQueryDirty compares the live query to the SAVED queries at the V5 envelope
// level (toQueryEnvelopes is mocked identity). Drive it via an input-sensitive
// toPerses so the envelope comparison — not getIsQueryModified — decides.
const SAVED_BASELINE = [{ id: 'saved-baseline' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const EDITED_ENVELOPES = [
{ id: 'edited-envelopes' },
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
beforeEach(() => {
mockToPerses.mockImplementation((query: Query) =>
query?.id === 'edited' ? EDITED_ENVELOPES : SAVED_BASELINE,
);
});
it('is query-dirty when the live query no longer serializes to the saved queries', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
expect(result.current.isQueryDirty).toBe(true);
});
it('is not query-dirty when the live query matches the baseline', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
it('is not query-dirty when the live query still serializes to the saved queries', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
expect(result.current.isQueryDirty).toBe(false);
});
it('buildSaveSpec bakes the live query in when dirty', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: CONVERTED_QUERIES,
queries: EDITED_ENVELOPES,
});
});
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toBe(spec);
});
it('anchors the baseline to savedQueries, not the draft the builder seeds from (View handoff / refresh)', () => {
// The draft carries the View-mode edit (the builder seeds from it), but the
// baseline is the persisted panel: a live query equal to the edited draft
// still reads dirty against the saved queries.
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const draft = makeDraft(EDITED_ENVELOPES);
const { result } = setup({ draft, savedQueries: SAVED_BASELINE });
expect(result.current.isQueryDirty).toBe(true);
});
it('falls back to the seed query as the baseline when there are no saved queries (new panel)', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
});
});

View File

@@ -23,6 +23,12 @@ import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new
* panel or the drilldown modal, where the seed is the baseline.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
time?: PanelQueryTimeOverride;
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
@@ -67,12 +73,15 @@ export interface UsePanelEditSessionReturn {
export function usePanelEditSession({
panel,
panelId,
savedPanel,
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
const { draft, spec, setSpec, isSpecDirty, reset } =
usePanelEditorDraft(panel);
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
panel,
savedPanel,
);
const panelKind = draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
@@ -93,6 +102,7 @@ export function usePanelEditSession({
refetch: query.refetch,
alwaysSerializeQuery,
signal: seedQuerySignal ? defaultSignal : undefined,
savedQueries: savedPanel?.spec.queries,
});
const { onChangePanelKind } = usePanelTypeSwitch({

View File

@@ -13,9 +13,14 @@ import type { PanelEditorDraftApi } from '../types';
* preview renders it through the dashboard's renderer registry and the save hook
* patches it without conversion. Everything the config pane edits flows through the
* single `spec`/`setSpec` pair.
*
* `savedPanel` is the persisted panel the dirty check compares against — distinct from
* `initialPanel` (the seed), which may carry unsaved edits handed off from View mode.
* Defaults to the seed when there's no separate saved baseline (a new panel).
*/
export function usePanelEditorDraft(
initialPanel: DashboardtypesPanelDTO,
savedPanel: DashboardtypesPanelDTO = initialPanel,
): PanelEditorDraftApi {
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
@@ -35,9 +40,9 @@ export function usePanelEditorDraft(
() =>
!isEqual(
{ ...draft, spec: { ...draft.spec, queries: null } },
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
{ ...savedPanel, spec: { ...savedPanel.spec, queries: null } },
),
[draft, initialPanel],
[draft, savedPanel],
);
return {

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -27,6 +28,12 @@ interface UsePanelEditorQuerySyncArgs {
alwaysSerializeQuery?: boolean;
/** Signal to seed a new panel's builder with — the kind's first supported signal. */
signal?: TelemetrytypesSignalDTO;
/**
* The persisted panel's queries — the dirty baseline. Distinct from `draft.spec.queries`,
* which the builder seeds from and may carry unsaved edits handed off from View mode. Omit
* for a new panel, where the seed query is the baseline.
*/
savedQueries?: DashboardtypesQueryDTO[];
}
interface UsePanelEditorQuerySyncApi {
@@ -53,43 +60,31 @@ export function usePanelEditorQuerySync({
refetch,
alwaysSerializeQuery = false,
signal,
savedQueries,
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
// Saved queries, captured once: seed the builder and serve as the restore target.
const savedQueries = draft.spec.queries;
const draftQueries = draft.spec.queries;
// A new panel has no saved query: seed from the kind's first supported signal
// instead of letting `fromPerses` fall back to the metrics default (which List
// doesn't support).
// A new panel has no saved query: seed from the kind's first supported signal rather
// than `fromPerses`'s metrics default (which List doesn't support).
const seedQuery = useMemo(
() =>
savedQueries.length === 0 && signal
draftQueries.length === 0 && signal
? initialQueriesMap[signal]
: fromPerses(savedQueries, panelType),
[savedQueries, panelType, signal],
: fromPerses(draftQueries, panelType),
[draftQueries, panelType, signal],
);
// Force-reset the builder to the SAVED panel on first render only, discarding a
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
// baseline is captured from the URL). After mount the URL syncs normally.
const isInitialRenderRef = useRef(true);
useShareBuilderUrl({
defaultValue: seedQuery,
forceReset: isInitialRenderRef.current,
});
useEffect(() => {
isInitialRenderRef.current = false;
}, []);
// No forceReset: seed the builder only when the URL carries no query, so an
// in-editor edit in the URL survives a refresh / browser Back-Forward.
useShareBuilderUrl({ defaultValue: seedQuery });
// Commit the live query into the draft (what the preview fetches). The dirty
// check compares against the SAVED query (`seedQuery`), not the URL-synced
// staged query, which can carry stale state across a refresh and read a real
// switch as "unchanged". Returns whether the draft changed.
// Commit the live query into the draft (what the preview fetches).
const commitQuery = useCallback(
(query: Query): boolean => {
const next = getIsQueryModified(query, seedQuery)
? toPerses(query, panelType)
: savedQueries;
: draftQueries;
// No-op guard at the V5 envelope level: equivalent wrappers (bare
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
// envelopes, so a structural compare would falsely dirty the draft.
@@ -100,7 +95,7 @@ export function usePanelEditorQuerySync({
setSpec({ ...draft.spec, queries: next });
return true;
},
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
[seedQuery, panelType, draftQueries, draft.spec, setSpec],
);
// Latest query/commit, read by the structural-change effect without re-subscribing.
@@ -110,7 +105,7 @@ export function usePanelEditorQuerySync({
queryRef.current = currentQuery;
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
// mount: the draft already holds the saved queries the builder is reset to.
// mount: the initial query is synced into the draft by the staged-query effect below.
const dataSources = useMemo(
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
[currentQuery.builder],
@@ -136,6 +131,15 @@ export function usePanelEditorQuerySync({
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
}, [currentQuery.queryType, dataSourceSignature]);
// Follow the staged (executed) query into the draft on a URL re-stage (mount
// hydration, browser Back/Forward) so the preview matches. Live edits touch only
// currentQuery, so they still wait for Run; commitQuery no-ops when unchanged.
useEffect(() => {
if (stagedQuery) {
commitRef.current(stagedQuery);
}
}, [stagedQuery]);
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
const runQuery = useCallback((): void => {
handleRunQuery();
@@ -144,20 +148,29 @@ export function usePanelEditorQuerySync({
}
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
// Dirty baseline: the builder's OWN normalized saved query (first non-null
// `stagedQuery` after the mount reset) — comparing builder-normalized to
// builder-normalized avoids serialization drift reading an untouched query as
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
// once and never moved by Stage & Run, so it stays anchored to saved.
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
useEffect(() => {
if (queryBaseline === null && stagedQuery) {
setQueryBaseline(stagedQuery);
}
}, [queryBaseline, stagedQuery]);
const isQueryDirty =
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
// Dirty = the live query no longer serializes to the SAVED panel's query, compared at
// the V5 envelope level. Anchoring to `savedQueries` (not the builder-seed) keeps a
// handed-off / URL-restored edit reading as dirty; routing both sides through the same
// `fromPerses → toPerses` round-trip stops builder-added defaults (absent from an older
// stored query) reading an untouched panel as modified. New panel: fall back to seed.
const baselineEnvelopes = useMemo(
() =>
toQueryEnvelopes(
toPerses(
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
panelType,
),
),
[savedQueries, seedQuery, panelType],
);
const isQueryDirty = useMemo(
() =>
!isEqual(
toQueryEnvelopes(toPerses(currentQuery, panelType)),
baselineEnvelopes,
),
[currentQuery, panelType, baselineEnvelopes],
);
const buildSaveSpec = useCallback(
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>

View File

@@ -6,6 +6,7 @@ import {
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
@@ -41,10 +42,22 @@ import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
@@ -68,6 +81,7 @@ function PanelEditorContainer({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
isEditable,
@@ -91,6 +105,7 @@ function PanelEditorContainer({
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
@@ -288,22 +303,24 @@ function PanelEditorContainer({
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ConfigProvider>
</ResizablePanel>
</ResizablePanelGroup>
</div>

View File

@@ -104,7 +104,9 @@ function ViewPanelModalContent({
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
};
return (

View File

@@ -0,0 +1,65 @@
import { act, renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useCreatePanel } from '../useCreatePanel';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
describe('useCreatePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
});
it('carries the relative time window onto the new-panel route', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('/dashboard/dash-1/panel/new');
expect(url).toContain('panelKind=timeSeries');
expect(url).toContain('layoutIndex=2');
expect(url).toContain('relativeTime=6h');
});
it('carries a custom absolute window and never a stray relativeTime', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('startTime=1000');
expect(url).toContain('endTime=2000');
expect(url).not.toContain('relativeTime');
expect(url).not.toContain('&&');
});
});

View File

@@ -0,0 +1,95 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useOpenPanelEditor } from '../useOpenPanelEditor';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
describe('useOpenPanelEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
});
it('carries the relative time window into the editor route', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9?relativeTime=6h',
undefined,
);
});
it('carries a custom absolute window as a start/end ms pair', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('startTime=1000');
expect(url).toContain('endTime=2000');
// A custom range must not also carry relativeTime (it would win on the editor).
expect(url).not.toContain('relativeTime');
});
it('omits the query string for an uninitialized custom window', () => {
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9',
undefined,
);
});
it('forwards handoff state as router location state', () => {
mockGlobalTime = { selectedTime: '1h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
const handoffState = { editSpec: { title: 'x' } } as never;
result.current('panel-9', { handoffState });
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9?relativeTime=1h',
{ state: handoffState },
);
});
it('merges search with the time window (leading ? tolerated)', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('new', { search: '?panelKind=timeSeries&layoutIndex=2' });
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('/dashboard/dash-1/panel/new?');
expect(url).toContain('panelKind=timeSeries');
expect(url).toContain('layoutIndex=2');
expect(url).toContain('relativeTime=6h');
});
});

View File

@@ -0,0 +1,43 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useTimeSearchParams } from '../useTimeSearchParams';
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
describe('useTimeSearchParams', () => {
it('returns a relativeTime query string for a relative selection', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toBe('relativeTime=6h');
});
it('returns an absolute ms pair for a custom selection', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toContain('startTime=1000');
expect(result.current).toContain('endTime=2000');
expect(result.current).not.toContain('relativeTime');
});
it('returns an empty string for an uninitialized custom window', () => {
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toBe('');
});
});

View File

@@ -1,11 +1,8 @@
import { useCallback, useState } from 'react';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useDashboardStore } from '../store/useDashboardStore';
import { useOpenPanelEditor } from './useOpenPanelEditor';
interface UseCreatePanelResult {
isPickerOpen: boolean;
@@ -24,8 +21,7 @@ interface UseCreatePanelResult {
* until save.
*/
export function useCreatePanel(): UseCreatePanelResult {
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const openPanelEditor = useOpenPanelEditor();
const [isPickerOpen, setIsPickerOpen] = useState(false);
// Captured on open, consumed on select.
@@ -43,15 +39,12 @@ export function useCreatePanel(): UseCreatePanelResult {
const createPanel = useCallback(
(panelKind: PanelKind, targetIndex?: number): void => {
setIsPickerOpen(false);
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
// Variable selection is read from the persisted store, not the URL.
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
openPanelEditor(NEW_PANEL_ID, {
search: newPanelSearch(panelKind, target),
});
},
[safeNavigate, dashboardId, layoutIndex],
[openPanelEditor, layoutIndex],
);
return {

View File

@@ -5,30 +5,39 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
import { useTimeSearchParams } from './useTimeSearchParams';
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. Variable selection is read from the
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
* panel.
*/
interface OpenPanelEditorOptions {
handoffState?: PanelEditorHandoffState;
/** Extra query merged into the editor URL (leading `?` optional). */
search?: string;
}
/** Opens the V2 panel editor, carrying the active time window in the URL. */
export function useOpenPanelEditor(): (
panelId: string,
handoffState?: PanelEditorHandoffState,
options?: OpenPanelEditorOptions,
) => void {
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
(panelId: string, options?: OpenPanelEditorOptions): void => {
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId,
});
const params = new URLSearchParams(options?.search);
new URLSearchParams(timeSearch).forEach((value, key) => {
params.set(key, value);
});
const search = params.toString();
safeNavigate(
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
handoffState ? { state: handoffState } : undefined,
search ? `${path}?${search}` : path,
options?.handoffState ? { state: options.handoffState } : undefined,
);
},
[safeNavigate, dashboardId],
[safeNavigate, dashboardId, timeSearch],
);
}

View File

@@ -0,0 +1,20 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
/** Active time window as a query string (no leading `?`), or `''` when unset. */
export function useTimeSearchParams(): string {
const { selectedTime, minTime, maxTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
return useMemo(
() => timeParamsFromGlobalTime({ selectedTime, minTime, maxTime }).toString(),
[selectedTime, minTime, maxTime],
);
}

View File

@@ -0,0 +1,51 @@
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { timeParamsFromGlobalTime } from '../timeUrlParams';
describe('timeParamsFromGlobalTime', () => {
it('emits relativeTime for a relative selection', () => {
const params = timeParamsFromGlobalTime({
selectedTime: '6h',
minTime: 0,
maxTime: 0,
});
expect(params.get('relativeTime')).toBe('6h');
// Mutually exclusive: no absolute pair alongside a relative range.
expect(params.has('startTime')).toBe(false);
expect(params.has('endTime')).toBe(false);
});
it('emits an absolute ms pair for a custom selection (converting from ns)', () => {
const params = timeParamsFromGlobalTime({
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
});
expect(params.get('startTime')).toBe('1000');
expect(params.get('endTime')).toBe('2000');
// A custom range must not carry a relativeTime that would win on the editor.
expect(params.has('relativeTime')).toBe(false);
});
it('carries a custom shorthand relative selection verbatim', () => {
const params = timeParamsFromGlobalTime({
selectedTime: '13m',
minTime: 0,
maxTime: 0,
});
expect(params.get('relativeTime')).toBe('13m');
});
it('emits nothing for an uninitialized custom window', () => {
const params = timeParamsFromGlobalTime({
selectedTime: 'custom',
minTime: 0,
maxTime: 0,
});
expect(params.toString()).toBe('');
});
});

View File

@@ -0,0 +1,39 @@
import { QueryParams } from 'constants/query';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import type { GlobalReducer } from 'types/reducer/globalTime';
type GlobalTimeSelection = Pick<
GlobalReducer,
'selectedTime' | 'minTime' | 'maxTime'
>;
/**
* Time-window URL params for the active selection. Derived from Redux (what the picker and
* panel queries read), not the URL: the legacy react-router and newer nuqs time writers fall
* out of sync, leaving a stale `relativeTime` that `DateTimeSelectionV2` prefers over an
* absolute range. Redux keeps them mutually exclusive (custom → start/end ms; else relativeTime).
*/
export function timeParamsFromGlobalTime({
selectedTime,
minTime,
maxTime,
}: GlobalTimeSelection): URLSearchParams {
const params = new URLSearchParams();
if (selectedTime === 'custom') {
if (minTime > 0 && maxTime > 0) {
params.set(
QueryParams.startTime,
String(Math.floor(minTime / NANO_SECOND_MULTIPLIER)),
);
params.set(
QueryParams.endTime,
String(Math.floor(maxTime / NANO_SECOND_MULTIPLIER)),
);
}
} else {
params.set(QueryParams.relativeTime, selectedTime);
}
return params;
}

View File

@@ -21,6 +21,7 @@ import {
parseNewPanelLayoutIndex,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
@@ -38,6 +39,7 @@ function PanelEditorPage(): JSX.Element {
}>();
const { search, state } = useLocation();
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
// Edits handed off from the View modal's drilldown — open the editor on these
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
@@ -105,10 +107,11 @@ function PanelEditorPage(): JSX.Element {
const layoutIndex = parseNewPanelLayoutIndex(search);
const backToDashboard = useCallback((): void => {
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
// variable selection from the persisted store, and time lives in Redux.
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [safeNavigate, dashboardId]);
// Drop editor-only URL state (variables come from the persisted store), but carry
// time so a custom range picked in the editor isn't reset to the dashboard default.
const path = generatePath(ROUTES.DASHBOARD, { dashboardId });
safeNavigate(timeSearch ? `${path}?${timeSearch}` : path);
}, [safeNavigate, dashboardId, timeSearch]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
@@ -137,6 +140,7 @@ function PanelEditorPage(): JSX.Element {
dashboardId={dashboardId}
panelId={panelId}
panel={panel}
savedPanel={existingPanel}
isNew={!!newKind}
layoutIndex={layoutIndex}
isEditable={isEditable}

View File

@@ -1,5 +1,20 @@
import { SelectProps } from 'antd';
import { ConfigProvider, SelectProps } from 'antd';
// eslint-disable-next-line no-restricted-imports
import { useContext } from 'react';
export const popupContainer: SelectProps['getPopupContainer'] = (
trigger,
): HTMLElement => trigger.parentNode;
/**
* Popup container for query-builder Selects. Prefers a container supplied by an
* ancestor antd `ConfigProvider` (set by hosts that render the builder inside a
* clipped/portaled surface — e.g. the panel editor's `overflow:hidden` resizable
* pane, or the View modal's focus-trapped dialog) and otherwise falls back to
* `trigger.parentNode`, the app-wide default. No `ConfigProvider` container is set
* app-wide, so surfaces that don't opt in keep the legacy behavior unchanged.
*/
export function useSelectPopupContainer(): SelectProps['getPopupContainer'] {
const { getPopupContainer } = useContext(ConfigProvider.ConfigContext);
return getPopupContainer ?? popupContainer;
}