Compare commits

...

3 Commits

Author SHA1 Message Date
Abhi kumar
c39d978601 Merge branch 'main' into feat/qb-dual-mode 2026-06-12 17:25:07 +05:30
Abhi Kumar
8e9857ccfa feat(hooks): add useBeforeUnloadWarning
Generic hook that raises the browser's native unsaved-changes confirmation on
refresh / tab-close while `enabled` is true. Reusable guard for any surface
holding unsaved in-memory state; in-app navigation is expected to be guarded
separately (e.g. a discard-changes modal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:21:10 +05:30
Abhi Kumar
3c9c3c98b1 feat(query-builder): dual-mode staged-query persistence (url + memory)
Introduce a CompositeQueryStore strategy so the query builder can persist its
staged query either in the compositeQuery URL param (default, unchanged) or in
React state (memory mode), selected via a nested QueryBuilderProvider.

- extract pure wire-format helpers into lib/compositeQuery
  (migrate / serialize / normalize) as the single source of truth
- add url + memory store implementations behind a shared interface
- provider consumes a store instead of touching the URL directly
- expose `mode` and `committedQuery` on the query builder context
- make useShareBuilderUrl store-agnostic (seeds via committedQuery, so it
  no longer navigates in memory mode)

Behavior-neutral for all existing url-mode surfaces. Memory mode has no
consumer yet; the dashboard panel editor adoption lands separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:21:10 +05:30
20 changed files with 998 additions and 164 deletions

View File

@@ -106,6 +106,8 @@ describe('MetricsSelect - signal source switching (standalone)', () => {
};
(mockedUseQueryBuilder as any).mockReturnValue({
mode: 'url',
committedQuery: null,
currentQuery: currentQueryObj,
stagedQuery: null,
lastUsedQuery: null,
@@ -263,6 +265,8 @@ describe('DataSource change - Logs to Traces', () => {
mockedUseQueryBuilder.mockReset();
mockedUseQueryBuilder.mockReturnValue({
mode: 'url',
committedQuery: null,
currentQuery: logsCurrentQuery,
stagedQuery: null,
lastUsedQuery: null,

View File

@@ -388,6 +388,8 @@ function LogsExplorerWithMockContext({
const contextValue = React.useMemo(
() => ({
mode: 'url' as const,
committedQuery: stagedQuery,
isDefaultQuery: (): boolean => false,
currentQuery,
stagedQuery,

View File

@@ -61,6 +61,8 @@ export const logsQueryRangeSuccessNewFormatResponse = {
};
export const mockQueryBuilderContextValue = {
mode: 'url' as const,
committedQuery: null,
isDefaultQuery: (): boolean => false,
currentQuery: {
...initialQueriesMap.logs,

View File

@@ -0,0 +1,38 @@
import { renderHook } from '@testing-library/react';
import useBeforeUnloadWarning from '../useBeforeUnloadWarning';
const dispatchBeforeUnload = (): Event => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event;
};
describe('useBeforeUnloadWarning', () => {
it('prevents unload while enabled', () => {
renderHook(() => useBeforeUnloadWarning(true));
expect(dispatchBeforeUnload().defaultPrevented).toBe(true);
});
it('does not prevent unload while disabled', () => {
renderHook(() => useBeforeUnloadWarning(false));
expect(dispatchBeforeUnload().defaultPrevented).toBe(false);
});
it('tracks enabled changes and cleans up on unmount', () => {
const { rerender, unmount } = renderHook(
({ enabled }) => useBeforeUnloadWarning(enabled),
{ initialProps: { enabled: false } },
);
expect(dispatchBeforeUnload().defaultPrevented).toBe(false);
rerender({ enabled: true });
expect(dispatchBeforeUnload().defaultPrevented).toBe(true);
unmount();
expect(dispatchBeforeUnload().defaultPrevented).toBe(false);
});
});

View File

@@ -0,0 +1,74 @@
import { act, renderHook } from '@testing-library/react';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useMemoryCompositeQueryStore } from '../useMemoryCompositeQueryStore';
const legacyLogsQuery: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
having: [{ columnName: 'count()', op: '>', value: 100 }],
},
],
},
};
describe('useMemoryCompositeQueryStore', () => {
it('starts empty when no initialQuery is provided', () => {
const { result } = renderHook(() => useMemoryCompositeQueryStore({}));
expect(result.current.mode).toBe('memory');
expect(result.current.query).toBeNull();
expect(result.current.panelType).toBeNull();
});
it('seeds query (with legacy-format migration) and panelType', () => {
const { result } = renderHook(() =>
useMemoryCompositeQueryStore({
initialQuery: legacyLogsQuery,
initialPanelType: PANEL_TYPES.TABLE,
}),
);
expect(result.current.query?.id).toBe(legacyLogsQuery.id);
expect(result.current.query?.builder.queryData[0].having).toStrictEqual({
expression: 'count() > 100',
});
expect(result.current.panelType).toBe(PANEL_TYPES.TABLE);
});
it('updates query and notifies onCommit when a query is committed', () => {
const onCommit = jest.fn();
const { result } = renderHook(() =>
useMemoryCompositeQueryStore({ onCommit }),
);
act(() => {
result.current.commit(initialQueriesMap.logs);
});
expect(result.current.query).toBe(initialQueriesMap.logs);
expect(onCommit).toHaveBeenCalledTimes(1);
expect(onCommit).toHaveBeenCalledWith(initialQueriesMap.logs);
});
it('ignores URL-mode commit options but still stores the query', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { result } = renderHook(() => useMemoryCompositeQueryStore({}));
act(() => {
result.current.commit(initialQueriesMap.logs, {
redirectingUrl: '/traces-explorer' as never,
newTab: true,
});
});
expect(result.current.query).toBe(initialQueriesMap.logs);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { serializeCompositeQueryParam } from 'lib/compositeQuery/compositeQuerySerialization';
import { useUrlCompositeQueryStore } from '../useUrlCompositeQueryStore';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
function createWrapper(
initialUrl: string,
): ({ children }: { children: React.ReactNode }) => JSX.Element {
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return React.createElement(
MemoryRouter,
{ initialEntries: [initialUrl] },
children,
) as JSX.Element;
};
}
function getNavigatedParams(): URLSearchParams {
const [navigatedUrl] = mockSafeNavigate.mock.calls[0];
return new URLSearchParams(navigatedUrl.split('?')[1]);
}
describe('useUrlCompositeQueryStore', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
});
it('reads the committed query and panelType from the URL', () => {
const initialParams = new URLSearchParams();
initialParams.set(
QueryParams.compositeQuery,
serializeCompositeQueryParam(initialQueriesMap.logs),
);
initialParams.set(QueryParams.panelTypes, PANEL_TYPES.TIME_SERIES);
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper(`/logs-explorer?${initialParams.toString()}`),
});
expect(result.current.mode).toBe('url');
expect(result.current.query?.id).toBe(initialQueriesMap.logs.id);
expect(result.current.panelType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('returns null query and panelType when the params are absent', () => {
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper('/logs-explorer'),
});
expect(result.current.query).toBeNull();
expect(result.current.panelType).toBeNull();
});
it('commits the query to the URL, resets pagination and drops activeLogId', () => {
const initialParams = new URLSearchParams();
initialParams.set(
QueryParams.pagination,
JSON.stringify({ limit: 25, offset: 50 }),
);
initialParams.set(QueryParams.activeLogId, 'some-log-id');
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper(`/logs-explorer?${initialParams.toString()}`),
});
act(() => {
result.current.commit(initialQueriesMap.logs);
});
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const [navigatedUrl, navigateOptions] = mockSafeNavigate.mock.calls[0];
expect(navigatedUrl.startsWith('/logs-explorer?')).toBe(true);
expect(navigateOptions).toStrictEqual({ newTab: undefined });
const params = getNavigatedParams();
expect(params.get(QueryParams.compositeQuery)).toBe(
serializeCompositeQueryParam(initialQueriesMap.logs),
);
expect(
JSON.parse(params.get(QueryParams.pagination) as string),
).toStrictEqual({ limit: 25, offset: 0 });
expect(params.get(QueryParams.activeLogId)).toBeNull();
});
it('honors redirectingUrl, stringified searchParams and newTab', () => {
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper('/logs-explorer'),
});
act(() => {
result.current.commit(initialQueriesMap.logs, {
searchParams: { [QueryParams.panelTypes]: PANEL_TYPES.TIME_SERIES },
redirectingUrl: ROUTES.TRACES_EXPLORER,
newTab: true,
});
});
const [navigatedUrl, navigateOptions] = mockSafeNavigate.mock.calls[0];
expect(navigatedUrl.startsWith(`${ROUTES.TRACES_EXPLORER}?`)).toBe(true);
expect(navigateOptions).toStrictEqual({ newTab: true });
const params = getNavigatedParams();
expect(params.get(QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TIME_SERIES),
);
});
it('passes searchParams through raw when shouldNotStringify is set', () => {
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper('/logs-explorer'),
});
act(() => {
result.current.commit(initialQueriesMap.logs, {
searchParams: { [QueryParams.panelTypes]: PANEL_TYPES.TIME_SERIES },
shouldNotStringify: true,
});
});
const params = getNavigatedParams();
expect(params.get(QueryParams.panelTypes)).toBe(PANEL_TYPES.TIME_SERIES);
});
});

View File

@@ -0,0 +1,31 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { CompositeQueryStoreMode } from 'types/common/queryBuilder';
export interface CommitCompositeQueryOptions {
searchParams?: Record<string, unknown>;
redirectingUrl?: (typeof ROUTES)[keyof typeof ROUTES];
shouldNotStringify?: boolean;
newTab?: boolean;
}
/**
* Storage strategy for the staged composite query. The provider edits
* `currentQuery` in React state; on stage/run the normalized query is
* committed to a store. The `url` store persists it in the
* `compositeQuery` URL param (shareable, survives reloads); the `memory`
* store keeps it in React state (clean URLs, host-controlled lifetime).
*/
export interface CompositeQueryStore {
mode: CompositeQueryStoreMode;
/** Parsed + migrated committed query, or null when none is committed. */
query: Query | null;
/** Panel type associated with the committed query, when known. */
panelType: PANEL_TYPES | null;
/**
* Persists an already-normalized query (see normalizeCompositeQuery).
* Options are url-mode navigation concerns; the memory store ignores them.
*/
commit: (query: Query, options?: CommitCompositeQueryOptions) => void;
}

View File

@@ -0,0 +1,60 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { migrateCompositeQuery } from 'lib/compositeQuery/migrateCompositeQuery';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { CommitCompositeQueryOptions, CompositeQueryStore } from './types';
export interface UseMemoryCompositeQueryStoreProps {
initialQuery?: Query;
initialPanelType?: PANEL_TYPES;
onCommit?: (query: Query) => void;
}
/**
* CompositeQueryStore backed by React state — the staged query never
* touches the URL. The seed query goes through the same legacy-format
* migration as URL parsing, so hosts can pass queries loaded from saved
* sources (alert rules, dashboard specs) as is. The store lives and dies
* with the provider that mounts it.
*/
export const useMemoryCompositeQueryStore = ({
initialQuery,
initialPanelType,
onCommit,
}: UseMemoryCompositeQueryStoreProps): CompositeQueryStore => {
const [query, setQuery] = useState<Query | null>(() =>
initialQuery ? migrateCompositeQuery(initialQuery) : null,
);
const [panelType] = useState<PANEL_TYPES | null>(initialPanelType ?? null);
const onCommitRef = useRef(onCommit);
useEffect(() => {
onCommitRef.current = onCommit;
}, [onCommit]);
const commit = useCallback(
(committedQuery: Query, options?: CommitCompositeQueryOptions): void => {
if (
process.env.NODE_ENV !== 'production' &&
options &&
(options.redirectingUrl || options.searchParams || options.newTab)
) {
// eslint-disable-next-line no-console
console.warn(
'[QueryBuilder] memory mode ignores URL commit options:',
options,
);
}
setQuery(committedQuery);
onCommitRef.current?.(committedQuery);
},
[],
);
return useMemo(
() => ({ mode: 'memory', query, panelType, commit }),
[query, panelType, commit],
);
};

View File

@@ -0,0 +1,78 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { serializeCompositeQueryParam } from 'lib/compositeQuery/compositeQuerySerialization';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { CommitCompositeQueryOptions, CompositeQueryStore } from './types';
/**
* CompositeQueryStore backed by the `compositeQuery` URL param — the
* default, shareable storage for the staged query.
*/
export const useUrlCompositeQueryStore = (): CompositeQueryStore => {
const urlQuery = useUrlQuery();
const location = useLocation();
const query = useGetCompositeQueryParam();
const { safeNavigate } = useSafeNavigate({
preventSameUrlNavigation: false,
});
const panelType = urlQuery.get(QueryParams.panelTypes) as PANEL_TYPES | null;
const commit = useCallback(
(committedQuery: Query, options?: CommitCompositeQueryOptions): void => {
const { searchParams, redirectingUrl, shouldNotStringify, newTab } =
options || {};
const pagination = urlQuery.get(QueryParams.pagination);
if (pagination) {
const parsedPagination = JSON.parse(pagination);
urlQuery.set(
QueryParams.pagination,
JSON.stringify({
limit: parsedPagination.limit,
offset: 0,
}),
);
}
urlQuery.set(
QueryParams.compositeQuery,
serializeCompositeQueryParam(committedQuery),
);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>
urlQuery.set(
param,
shouldNotStringify
? (searchParams[param] as string)
: JSON.stringify(searchParams[param]),
),
);
}
// Remove Hidden Filters from URL query parameters on query change
urlQuery.delete(QueryParams.activeLogId);
const generatedUrl = redirectingUrl
? `${redirectingUrl}?${urlQuery}`
: `${location.pathname}?${urlQuery}`;
safeNavigate(generatedUrl, { newTab });
},
[location.pathname, safeNavigate, urlQuery],
);
return useMemo(
() => ({ mode: 'url', query, panelType, commit }),
[query, panelType, commit],
);
};

View File

@@ -1,72 +1,14 @@
import { useMemo } from 'react';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { parseCompositeQueryParam } from 'lib/compositeQuery/compositeQuerySerialization';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
try {
if (!compositeQuery) {
return null;
}
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
parsedCompositeQuery = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =
parsedCompositeQuery.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(query.having);
convertedQuery.having = convertedHaving;
}
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;
});
}
} catch (e) {
parsedCompositeQuery = null;
}
return parsedCompositeQuery;
}, [urlQuery]);
return useMemo(
() => parseCompositeQueryParam(urlQuery.get(QueryParams.compositeQuery)),
[urlQuery],
);
};

View File

@@ -1,35 +1,36 @@
import { useEffect } from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetCompositeQueryParam } from './useGetCompositeQueryParam';
import { useQueryBuilder } from './useQueryBuilder';
export type UseShareBuilderUrlParams = {
defaultValue: Query;
/** Force reset the query regardless of URL state */
/** Force reset the query regardless of the committed query state */
forceReset?: boolean;
};
/**
* Seeds the query builder with a default query when nothing is committed
* yet. In url mode this writes the `compositeQuery` URL param (preserving
* the original "share builder url" behavior); in memory mode it commits to
* the in-memory store without touching the URL.
*/
export const useShareBuilderUrl = ({
defaultValue,
forceReset = false,
}: UseShareBuilderUrlParams): void => {
const { resetQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const urlQuery = useUrlQuery();
const compositeQuery = useGetCompositeQueryParam();
const { committedQuery, resetQuery, redirectWithQueryBuilderData } =
useQueryBuilder();
useEffect(() => {
if (!compositeQuery || forceReset) {
if (!committedQuery || forceReset) {
resetQuery(defaultValue);
redirectWithQueryBuilderData(defaultValue);
}
}, [
defaultValue,
urlQuery,
redirectWithQueryBuilderData,
compositeQuery,
committedQuery,
resetQuery,
forceReset,
]);

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
/**
* Shows the browser's native "unsaved changes" confirmation when the user
* refreshes or closes the tab while `enabled` is true. In-app navigation is
* unaffected — guard that separately (e.g. with a discard-changes modal).
*/
const useBeforeUnloadWarning = (enabled: boolean): void => {
useEffect(() => {
if (!enabled) {
return undefined;
}
const handleBeforeUnload = (event: BeforeUnloadEvent): void => {
event.preventDefault();
// Chrome requires returnValue to be set for the dialog to appear
event.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return (): void =>
window.removeEventListener('beforeunload', handleBeforeUnload);
}, [enabled]);
};
export default useBeforeUnloadWarning;

View File

@@ -0,0 +1,185 @@
import { initialQueriesMap, initialQueryState } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
parseCompositeQueryParam,
serializeCompositeQueryParam,
} from '../compositeQuerySerialization';
import { migrateCompositeQuery } from '../migrateCompositeQuery';
import { normalizeCompositeQuery } from '../normalizeCompositeQuery';
const legacyLogsQuery: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
aggregations: undefined,
aggregateOperator: 'count',
filter: undefined,
filters: {
items: [
{
id: 'service-name-filter',
key: {
id: 'service.name',
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
op: '=',
value: 'frontend',
},
],
op: 'AND',
},
having: [{ columnName: 'count()', op: '>', value: 100 }],
},
],
},
};
describe('migrateCompositeQuery', () => {
it('converts legacy filters array to filter expression', () => {
const migrated = migrateCompositeQuery(legacyLogsQuery);
const { filter } = migrated.builder.queryData[0];
expect(filter?.expression).toContain('service.name');
expect(filter?.expression).toContain('frontend');
});
it('converts legacy having array to having expression', () => {
const migrated = migrateCompositeQuery(legacyLogsQuery);
expect(migrated.builder.queryData[0].having).toStrictEqual({
expression: 'count() > 100',
});
});
it('converts legacy aggregateOperator to aggregations', () => {
const migrated = migrateCompositeQuery(legacyLogsQuery);
expect(migrated.builder.queryData[0].aggregations).toStrictEqual([
{ expression: 'count()' },
]);
});
it('keeps existing aggregations and filter expression untouched', () => {
const newFormatQuery: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: "severity_text = 'ERROR'" },
},
],
},
};
const migrated = migrateCompositeQuery(newFormatQuery);
expect(migrated.builder.queryData[0].aggregations).toStrictEqual(
newFormatQuery.builder.queryData[0].aggregations,
);
expect(migrated.builder.queryData[0].filter?.expression).toBe(
"severity_text = 'ERROR'",
);
});
it('returns the query as is when builder queryData is missing', () => {
const queryWithoutBuilder = {
queryType: EQueryType.PROM,
} as unknown as Query;
expect(migrateCompositeQuery(queryWithoutBuilder)).toBe(queryWithoutBuilder);
});
});
describe('parseCompositeQueryParam / serializeCompositeQueryParam', () => {
it('round-trips a composite query through serialize and parse', () => {
const query: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: "key1 = 'a+b' AND key2 = 'c d'" },
},
],
},
};
const parsed = parseCompositeQueryParam(serializeCompositeQueryParam(query));
expect(parsed?.id).toBe(query.id);
expect(parsed?.queryType).toBe(query.queryType);
expect(parsed?.builder.queryData[0].filter?.expression).toBe(
"key1 = 'a+b' AND key2 = 'c d'",
);
});
it('treats literal + characters as spaces (legacy URL encoding)', () => {
const serialized = serializeCompositeQueryParam(initialQueriesMap.logs);
const legacyEncoded = serialized.replace(/%20/g, '+');
const parsed = parseCompositeQueryParam(legacyEncoded);
expect(parsed?.id).toBe(initialQueriesMap.logs.id);
});
it('returns null for missing or unparseable values', () => {
expect(parseCompositeQueryParam(null)).toBeNull();
expect(parseCompositeQueryParam('')).toBeNull();
expect(parseCompositeQueryParam('not-a-json')).toBeNull();
});
});
describe('normalizeCompositeQuery', () => {
it('fills defaults for an empty partial query', () => {
const normalized = normalizeCompositeQuery({});
expect(normalized.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(normalized.builder).toBe(initialQueryState.builder);
expect(normalized.promql).toBe(initialQueryState.promql);
expect(normalized.clickhouse_sql).toBe(initialQueryState.clickhouse_sql);
expect(normalized.unit).toBe(initialQueryState.unit);
});
it('falls back to QUERY_BUILDER for an invalid queryType', () => {
const normalized = normalizeCompositeQuery({
queryType: 'bogus' as unknown as EQueryType,
});
expect(normalized.queryType).toBe(EQueryType.QUERY_BUILDER);
});
it('falls back to defaults for empty builder, promql and clickhouse queries', () => {
const normalized = normalizeCompositeQuery({
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
promql: [],
clickhouse_sql: [],
});
expect(normalized.builder).toBe(initialQueryState.builder);
expect(normalized.promql).toBe(initialQueryState.promql);
expect(normalized.clickhouse_sql).toBe(initialQueryState.clickhouse_sql);
});
it('preserves provided values and stamps a fresh id on every call', () => {
const query = initialQueriesMap.logs;
const first = normalizeCompositeQuery(query);
const second = normalizeCompositeQuery(query);
expect(first.queryType).toBe(query.queryType);
expect(first.builder).toBe(query.builder);
expect(first.id).not.toBe(query.id);
expect(first.id).not.toBe(second.id);
});
});

View File

@@ -0,0 +1,35 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { migrateCompositeQuery } from './migrateCompositeQuery';
/**
* Parses a raw `compositeQuery` URL param value into a Query, migrating
* old-format queries to the new format. Returns null when the value is
* missing or unparseable.
*/
export const parseCompositeQueryParam = (
compositeQuery: string | null,
): Query | null => {
if (!compositeQuery) {
return null;
}
try {
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
const parsedCompositeQuery: Query = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
return migrateCompositeQuery(parsedCompositeQuery);
} catch (e) {
return null;
}
};
/**
* Serializes a Query into the URL-safe string stored in the
* `compositeQuery` URL param. Inverse of parseCompositeQueryParam.
*/
export const serializeCompositeQueryParam = (query: Query): string =>
encodeURIComponent(JSON.stringify(query));

View File

@@ -0,0 +1,60 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Converts a composite query from the old format to the new format for each
* query in builder.queryData:
* - `filters` array -> `filter.expression`
* - `having` array -> `having` expression object
* - `aggregateOperator` -> `aggregations` object
*
* Queries already in the new format pass through unchanged.
*/
export const migrateCompositeQuery = (query: Query): Query => {
if (!query?.builder?.queryData) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((queryData) => {
const existingExpression = queryData.filter?.expression || '';
const convertedQuery = { ...queryData };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
queryData.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(queryData.having)) {
convertedQuery.having = convertHavingToExpression(queryData.having);
}
// Convert aggregation if needed
if (!queryData.aggregations && queryData.aggregateOperator) {
convertedQuery.aggregations = convertAggregationToExpression({
aggregateOperator: queryData.aggregateOperator,
aggregateAttribute: queryData.aggregateAttribute as BaseAutocompleteData,
dataSource: queryData.dataSource,
timeAggregation: queryData.timeAggregation,
spaceAggregation: queryData.spaceAggregation,
reduceTo: queryData.reduceTo,
temporality: queryData.temporality,
}) as IBuilderQuery['aggregations']; // Narrow the mixed aggregation array to the union of homogeneous arrays
}
return convertedQuery;
}),
},
};
};

View File

@@ -0,0 +1,40 @@
import { initialQueryState } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
/**
* Fills a partial composite query with defaults from initialQueryState and
* stamps a fresh id. This is the normalization applied before a query is
* committed (staged) — extracted from redirectWithQueryBuilderData.
*/
export const normalizeCompositeQuery = (query: Partial<Query>): Query => {
const queryType =
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
? EQueryType.QUERY_BUILDER
: query.queryType;
const builder =
!query.builder || query.builder.queryData?.length === 0
? initialQueryState.builder
: query.builder;
const promql =
!query.promql || query.promql.length === 0
? initialQueryState.promql
: query.promql;
const clickhouseSql =
!query.clickhouse_sql || query.clickhouse_sql.length === 0
? initialQueryState.clickhouse_sql
: query.clickhouse_sql;
return {
queryType,
builder,
promql,
clickhouse_sql: clickhouseSql,
id: uuid(),
unit: query.unit || initialQueryState.unit,
};
};

View File

@@ -177,6 +177,8 @@ describe('Logs Explorer Tests', () => {
>
<QueryBuilderContext.Provider
value={{
mode: 'url',
committedQuery: null,
isDefaultQuery: (): boolean => false,
currentQuery: {
...initialQueriesMap.metrics,

View File

@@ -10,7 +10,6 @@ import {
} from 'react';
import { useLocation } from 'react-router-dom';
import { isQueryUpdatedInView } from 'components/ExplorerCard/utils';
import { QueryParams } from 'constants/query';
import {
alphabet,
baseAutoCompleteIdKeysOrder,
@@ -21,7 +20,6 @@ import {
initialQueryBuilderFormTraceOperatorValues,
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
initialQueryState,
initialSingleQueryMap,
MAX_FORMULAS,
MAX_QUERIES,
@@ -34,10 +32,10 @@ import {
PartialPanelTypes,
} from 'container/NewWidget/utils';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useMemoryCompositeQueryStore } from 'hooks/queryBuilder/compositeQueryStore/useMemoryCompositeQueryStore';
import { useUrlCompositeQueryStore } from 'hooks/queryBuilder/compositeQueryStore/useUrlCompositeQueryStore';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { normalizeCompositeQuery } from 'lib/compositeQuery/normalizeCompositeQuery';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
@@ -65,9 +63,10 @@ import {
ReduceOperators,
} from 'types/common/queryBuilder';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
import { v4 as uuid } from 'uuid';
export const QueryBuilderContext = createContext<QueryBuilderContextType>({
mode: 'url',
committedQuery: null,
currentQuery: initialQueriesMap.metrics,
supersetQuery: initialQueriesMap.metrics,
lastUsedQuery: null,
@@ -102,10 +101,28 @@ export const QueryBuilderContext = createContext<QueryBuilderContextType>({
isDefaultQuery: () => false,
});
export function QueryBuilderProvider({
children,
}: PropsWithChildren): JSX.Element {
const urlQuery = useUrlQuery();
export type QueryBuilderProviderProps = PropsWithChildren<
| {
/** Default: the staged query is stored in the `compositeQuery` URL param. */
mode?: 'url';
}
| {
/** The staged query is kept in memory — the URL is never touched. */
mode: 'memory';
/** Seed for the staged query; runs the same legacy-format migration as URL parsing. */
initialQuery?: Query;
initialPanelType?: PANEL_TYPES;
/** Fired with the normalized query every time it is staged (Stage & Run). */
onStagedQueryChange?: (query: Query) => void;
}
>;
export function QueryBuilderProvider(
props: QueryBuilderProviderProps,
): JSX.Element {
const { children } = props;
const memoryModeProps = props.mode === 'memory' ? props : null;
const location = useLocation();
const currentPathnameRef = useRef<string | null>(location.pathname);
@@ -114,7 +131,18 @@ export function QueryBuilderProvider({
const [calledFromHandleRunQuery, setCalledFromHandleRunQuery] =
useState<boolean>(false);
const compositeQueryParam = useGetCompositeQueryParam();
// Rules of hooks: both store hooks always run; only the selected one is used.
const urlCompositeQueryStore = useUrlCompositeQueryStore();
const memoryCompositeQueryStore = useMemoryCompositeQueryStore({
initialQuery: memoryModeProps?.initialQuery,
initialPanelType: memoryModeProps?.initialPanelType,
onCommit: memoryModeProps?.onStagedQueryChange,
});
const compositeQueryStore = memoryModeProps
? memoryCompositeQueryStore
: urlCompositeQueryStore;
const compositeQueryParam = compositeQueryStore.query;
const { queryType: queryTypeParam, ...queryState } =
compositeQueryParam || initialQueriesMap.metrics;
@@ -122,12 +150,8 @@ export function QueryBuilderProvider({
null,
);
const panelTypeQueryParams = urlQuery.get(
QueryParams.panelTypes,
) as PANEL_TYPES | null;
const [panelType, setPanelType] = useState<PANEL_TYPES | null>(
panelTypeQueryParams,
compositeQueryStore.panelType,
);
const [currentQuery, setCurrentQuery] = useState<QueryState>(queryState);
@@ -935,9 +959,7 @@ export function QueryBuilderProvider({
[panelType, stagedQuery],
);
const { safeNavigate } = useSafeNavigate({
preventSameUrlNavigation: false,
});
const { commit: commitCompositeQuery } = compositeQueryStore;
const redirectWithQueryBuilderData = useCallback(
(
@@ -947,74 +969,14 @@ export function QueryBuilderProvider({
shouldNotStringify?: boolean,
newTab?: boolean,
) => {
const queryType =
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
? EQueryType.QUERY_BUILDER
: query.queryType;
const builder =
!query.builder || query.builder.queryData?.length === 0
? initialQueryState.builder
: query.builder;
const promql =
!query.promql || query.promql.length === 0
? initialQueryState.promql
: query.promql;
const clickhouseSql =
!query.clickhouse_sql || query.clickhouse_sql.length === 0
? initialQueryState.clickhouse_sql
: query.clickhouse_sql;
const currentGeneratedQuery: Query = {
queryType,
builder,
promql,
clickhouse_sql: clickhouseSql,
id: uuid(),
unit: query.unit || initialQueryState.unit,
};
const pagination = urlQuery.get(QueryParams.pagination);
if (pagination) {
const parsedPagination = JSON.parse(pagination);
urlQuery.set(
QueryParams.pagination,
JSON.stringify({
limit: parsedPagination.limit,
offset: 0,
}),
);
}
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>
urlQuery.set(
param,
shouldNotStringify
? (searchParams[param] as string)
: JSON.stringify(searchParams[param]),
),
);
}
// Remove Hidden Filters from URL query parameters on query change
urlQuery.delete(QueryParams.activeLogId);
const generatedUrl = redirectingUrl
? `${redirectingUrl}?${urlQuery}`
: `${location.pathname}?${urlQuery}`;
safeNavigate(generatedUrl, { newTab });
commitCompositeQuery(normalizeCompositeQuery(query), {
searchParams,
redirectingUrl,
shouldNotStringify,
newTab,
});
},
[location.pathname, safeNavigate, urlQuery],
[commitCompositeQuery],
);
const handleSetConfig = useCallback(
@@ -1075,12 +1037,16 @@ export function QueryBuilderProvider({
if (location.pathname !== currentPathnameRef.current) {
currentPathnameRef.current = location.pathname;
setStagedQuery(null);
// reset the last used query to 0 when navigating away from the page
setLastUsedQuery(0);
setCalledFromHandleRunQuery(false);
// In memory mode the store lives and dies with the provider mount,
// so navigation must not clear the staged query.
if (compositeQueryStore.mode === 'url') {
setStagedQuery(null);
// reset the last used query to 0 when navigating away from the page
setLastUsedQuery(0);
setCalledFromHandleRunQuery(false);
}
}
}, [location.pathname]);
}, [location.pathname, compositeQueryStore.mode]);
// Separate useEffect to handle initQueryBuilderData after pathname changes
useEffect(() => {
@@ -1159,6 +1125,8 @@ export function QueryBuilderProvider({
const contextValues: QueryBuilderContextType = useMemo(
() => ({
mode: compositeQueryStore.mode,
committedQuery: compositeQueryStore.query,
currentQuery: query,
supersetQuery: superQuery,
lastUsedQuery,
@@ -1193,6 +1161,8 @@ export function QueryBuilderProvider({
isStagedQueryUpdated,
}),
[
compositeQueryStore.mode,
compositeQueryStore.query,
query,
superQuery,
lastUsedQuery,

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { act, renderHook } from '@testing-library/react';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import { QueryBuilderProvider } from '../QueryBuilder';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockOnStagedQueryChange = jest.fn();
function createMemoryModeWrapper(
initialQuery?: Query,
): ({ children }: { children: React.ReactNode }) => JSX.Element {
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<MemoryRouter initialEntries={['/dashboard/test-dashboard']}>
<QueryBuilderProvider
mode="memory"
initialQuery={initialQuery}
initialPanelType={PANEL_TYPES.TABLE}
onStagedQueryChange={mockOnStagedQueryChange}
>
{children}
</QueryBuilderProvider>
</MemoryRouter>
);
};
}
describe('QueryBuilderProvider in memory mode', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
mockOnStagedQueryChange.mockClear();
});
it('exposes memory mode and seeds staged/current query from initialQuery', () => {
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: createMemoryModeWrapper(initialQueriesMap.logs),
});
expect(result.current.mode).toBe('memory');
expect(result.current.panelType).toBe(PANEL_TYPES.TABLE);
expect(result.current.stagedQuery?.id).toBe(initialQueriesMap.logs.id);
expect(result.current.currentQuery.builder.queryData[0].dataSource).toBe(
initialQueriesMap.logs.builder.queryData[0].dataSource,
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(mockOnStagedQueryChange).not.toHaveBeenCalled();
});
it('stages on handleRunQuery without navigating and notifies the host', () => {
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: createMemoryModeWrapper(initialQueriesMap.logs),
});
const seededStagedQueryId = result.current.stagedQuery?.id;
act(() => {
result.current.handleRunQuery();
});
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(mockOnStagedQueryChange).toHaveBeenCalledTimes(1);
const stagedByHost = mockOnStagedQueryChange.mock.calls[0][0];
expect(stagedByHost.builder.queryData[0].dataSource).toBe(
initialQueriesMap.logs.builder.queryData[0].dataSource,
);
expect(result.current.stagedQuery?.id).toBe(stagedByHost.id);
expect(result.current.stagedQuery?.id).not.toBe(seededStagedQueryId);
});
it('useShareBuilderUrl seeds the default query in memory without navigating', () => {
const { result } = renderHook(
(): QueryBuilderContextType => {
useShareBuilderUrl({ defaultValue: initialQueriesMap.traces });
return useQueryBuilder();
},
{ wrapper: createMemoryModeWrapper() },
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(result.current.committedQuery).not.toBeNull();
expect(result.current.stagedQuery?.builder.queryData[0].dataSource).toBe(
initialQueriesMap.traces.builder.queryData[0].dataSource,
);
});
it('useShareBuilderUrl does not clobber a seeded initialQuery', () => {
const { result } = renderHook(
(): QueryBuilderContextType => {
useShareBuilderUrl({ defaultValue: initialQueriesMap.traces });
return useQueryBuilder();
},
{ wrapper: createMemoryModeWrapper(initialQueriesMap.logs) },
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(result.current.stagedQuery?.builder.queryData[0].dataSource).toBe(
initialQueriesMap.logs.builder.queryData[0].dataSource,
);
});
it('redirectWithQueryBuilderData commits in memory without navigating', () => {
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: createMemoryModeWrapper(initialQueriesMap.logs),
});
act(() => {
result.current.redirectWithQueryBuilderData(initialQueriesMap.traces);
});
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(mockOnStagedQueryChange).toHaveBeenCalledTimes(1);
expect(result.current.stagedQuery?.builder.queryData[0].dataSource).toBe(
initialQueriesMap.traces.builder.queryData[0].dataSource,
);
});
});

View File

@@ -233,7 +233,16 @@ export type QueryBuilderData = {
queryTraceOperator: IBuilderTraceOperator[];
};
export type CompositeQueryStoreMode = 'url' | 'memory';
export type QueryBuilderContextType = {
/** Where the staged query is persisted: the URL (default) or in memory. */
mode: CompositeQueryStoreMode;
/**
* The query currently persisted in the store (URL param or memory),
* synchronously available — unlike stagedQuery, which is set by effects.
*/
committedQuery: Query | null;
currentQuery: Query;
stagedQuery: Query | null;
lastUsedQuery: number | null;