From d3450d75a4916f29369d85f616dba6fd83077236 Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Wed, 21 Jun 2023 10:06:28 +0100 Subject: [PATCH] Explore: URL migrations & improved state management (#69692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Piotr Jamróz --- .betterer.results | 26 +- e2e/various-suite/explore.spec.ts | 24 +- packages/grafana-data/src/types/explore.ts | 6 +- public/app/core/utils/explore.test.ts | 126 +---- public/app/core/utils/explore.ts | 68 +-- public/app/core/utils/richHistory.ts | 1 - .../AddToDashboard/AddToDashboardModal.tsx | 4 +- .../explore/AddToDashboard/index.test.tsx | 34 +- .../features/explore/AddToDashboard/index.tsx | 4 +- public/app/features/explore/Explore.test.tsx | 3 +- public/app/features/explore/Explore.tsx | 3 +- .../app/features/explore/ExploreActions.tsx | 28 +- .../app/features/explore/ExplorePage.test.tsx | 306 +----------- public/app/features/explore/ExplorePage.tsx | 33 +- .../features/explore/ExplorePaneContainer.tsx | 22 +- .../explore/ExploreQueryInspector.test.tsx | 3 +- .../explore/ExploreQueryInspector.tsx | 6 +- .../features/explore/ExploreTimeControls.tsx | 3 +- .../app/features/explore/ExploreToolbar.tsx | 11 +- .../app/features/explore/Logs/Logs.test.tsx | 17 +- public/app/features/explore/Logs/Logs.tsx | 3 +- .../features/explore/Logs/LogsContainer.tsx | 6 +- .../NodeGraph/NodeGraphContainer.test.tsx | 5 +- .../explore/NodeGraph/NodeGraphContainer.tsx | 4 +- .../app/features/explore/QueryRows.test.tsx | 4 +- public/app/features/explore/QueryRows.tsx | 5 +- .../RawPrometheusContainer.test.tsx | 4 +- .../RawPrometheus/RawPrometheusContainer.tsx | 4 +- .../explore/ResponseErrorContainer.test.tsx | 3 +- .../explore/ResponseErrorContainer.tsx | 4 +- .../explore/RichHistory/RichHistory.test.tsx | 3 +- .../explore/RichHistory/RichHistory.tsx | 12 +- .../RichHistory/RichHistoryCard.test.tsx | 4 +- .../explore/RichHistory/RichHistoryCard.tsx | 4 +- .../RichHistory/RichHistoryContainer.test.tsx | 3 +- .../RichHistory/RichHistoryContainer.tsx | 5 +- .../RichHistoryQueriesTab.test.tsx | 3 +- .../RichHistory/RichHistoryQueriesTab.tsx | 4 +- .../RichHistoryStarredTab.test.tsx | 3 +- .../RichHistory/RichHistoryStarredTab.tsx | 4 +- .../explore/Table/TableContainer.test.tsx | 3 +- .../features/explore/Table/TableContainer.tsx | 4 +- .../explore/TraceView/TraceView.test.tsx | 3 +- .../features/explore/TraceView/TraceView.tsx | 5 +- .../TraceView/TraceViewContainer.test.tsx | 3 +- .../explore/TraceView/TraceViewContainer.tsx | 3 +- .../explore/hooks/useExplorePageTitle.test.ts | 96 ++++ .../explore/hooks/useExplorePageTitle.ts | 55 ++- .../explore/hooks/useStateSync/index.test.tsx | 443 ++++++++++++++++++ .../index.ts} | 148 +++--- .../hooks/useStateSync/migrators/types.ts | 16 + .../hooks/useStateSync/migrators/v0.test.ts | 125 +++++ .../hooks/useStateSync/migrators/v0.ts | 66 +++ .../hooks/useStateSync/migrators/v1.test.ts | 118 +++++ .../hooks/useStateSync/migrators/v1.ts | 89 ++++ .../explore/hooks/useStateSync/parseURL.ts | 44 ++ .../features/explore/hooks/useStopQueries.ts | 23 - .../explore/hooks/useTimeSrvFix.test.tsx | 45 ++ .../features/explore/hooks/useTimeSrvFix.ts | 1 + public/app/features/explore/hooks/utils.ts | 7 + .../explore/spec/datasourceState.test.tsx | 17 +- .../features/explore/spec/helper/assert.ts | 25 +- .../explore/spec/helper/interactions.ts | 33 +- .../features/explore/spec/helper/setup.tsx | 14 +- .../app/features/explore/spec/query.test.tsx | 69 +-- .../explore/spec/queryHistory.test.tsx | 27 +- .../features/explore/state/datasource.test.ts | 4 +- .../app/features/explore/state/datasource.ts | 5 +- .../app/features/explore/state/explorePane.ts | 17 +- public/app/features/explore/state/history.ts | 32 +- .../app/features/explore/state/main.test.ts | 36 +- public/app/features/explore/state/main.ts | 69 +-- .../app/features/explore/state/query.test.ts | 100 ++-- public/app/features/explore/state/query.ts | 72 +-- .../features/explore/state/selectors.test.ts | 19 - .../app/features/explore/state/selectors.ts | 26 +- .../app/features/explore/state/time.test.ts | 6 +- public/app/features/explore/state/time.ts | 19 +- .../features/explore/useLiveTailControls.ts | 6 +- .../cloudwatch/components/LogsQueryField.tsx | 3 +- public/app/types/explore.ts | 20 +- public/test/helpers/TestProvider.tsx | 9 +- 82 files changed, 1562 insertions(+), 1178 deletions(-) create mode 100644 public/app/features/explore/hooks/useExplorePageTitle.test.ts create mode 100644 public/app/features/explore/hooks/useStateSync/index.test.tsx rename public/app/features/explore/hooks/{useStateSync.ts => useStateSync/index.ts} (77%) create mode 100644 public/app/features/explore/hooks/useStateSync/migrators/types.ts create mode 100644 public/app/features/explore/hooks/useStateSync/migrators/v0.test.ts create mode 100644 public/app/features/explore/hooks/useStateSync/migrators/v0.ts create mode 100644 public/app/features/explore/hooks/useStateSync/migrators/v1.test.ts create mode 100644 public/app/features/explore/hooks/useStateSync/migrators/v1.ts create mode 100644 public/app/features/explore/hooks/useStateSync/parseURL.ts delete mode 100644 public/app/features/explore/hooks/useStopQueries.ts create mode 100644 public/app/features/explore/hooks/useTimeSrvFix.test.tsx create mode 100644 public/app/features/explore/hooks/utils.ts delete mode 100644 public/app/features/explore/state/selectors.test.ts diff --git a/.betterer.results b/.betterer.results index e605baa2fd0..b1a0ad4f169 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1675,8 +1675,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], "public/app/core/utils/fetch.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -2504,9 +2503,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/explore/ExplorePage.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/explore/ExploreQueryInspector.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -2567,11 +2563,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/explore/hooks/useStateSync.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] - ], "public/app/features/explore/spec/helper/setup.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -2582,24 +2573,9 @@ exports[`better eslint`] = { "public/app/features/explore/spec/queryHistory.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/explore/state/history.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/explore/state/main.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/explore/state/query.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/explore/state/time.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/explore/state/time.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] - ], "public/app/features/explore/state/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/e2e/various-suite/explore.spec.ts b/e2e/various-suite/explore.spec.ts index a89384823ac..09125575e37 100644 --- a/e2e/various-suite/explore.spec.ts +++ b/e2e/various-suite/explore.spec.ts @@ -28,34 +28,12 @@ e2e.scenario({ cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); - cy.location().then((loc) => { - const params = new URLSearchParams(loc.search); - const leftJSON = JSON.parse(params.get('left')); - expect(leftJSON.range.to).to.equal('now'); - expect(leftJSON.range.from).to.equal('now-1h'); - - cy.get('body').click(); - cy.get('body').type('t{leftarrow}'); - - cy.location().should((locPostKeypress) => { - const params = new URLSearchParams(locPostKeypress.search); - const leftJSON = JSON.parse(params.get('left')); - // be sure the keypress affected the time window - expect(leftJSON.range.to).to.not.equal('now'); - expect(leftJSON.range.from).to.not.equal('now-1h'); - // be sure the url does not contain dashboard range values - // eslint wants this to be a function, so we use this instead of to.be.false - expect(params.has('to')).to.equal(false); - expect(params.has('from')).to.equal(false); - }); - }); - const canvases = e2e().get('canvas'); canvases.should('have.length', 1); // Both queries above should have been run and be shown in the query history e2e.components.QueryTab.queryHistoryButton().should('be.visible').click(); - e2e.components.QueryHistory.queryText().should('have.length', 2).should('contain', 'csv_metric_values'); + e2e.components.QueryHistory.queryText().should('have.length', 1).should('contain', 'csv_metric_values'); // delete all queries cy.get('button[title="Delete query"]').each((button) => { diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index 136d57ffaa1..d5a837e38ed 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -1,15 +1,15 @@ +import { DataQuery } from '@grafana/schema'; + import { PreferredVisualisationType } from './data'; -import { DataQuery } from './query'; import { RawTimeRange, TimeRange } from './time'; type AnyQuery = DataQuery & Record; /** @internal */ export interface ExploreUrlState { - datasource: string; + datasource: string | null; queries: T[]; range: RawTimeRange; - context?: string; panelsState?: ExplorePanelsState; } diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index df14bb1beef..9e2156bb41b 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -2,15 +2,13 @@ import { dateTime, ExploreUrlState, LogsSortOrder } from '@grafana/data'; import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; import { RefreshPicker } from '@grafana/ui'; import store from 'app/core/store'; -import { ExploreId } from 'app/types'; +import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; import { DatasourceSrvMock, MockDataSourceApi } from '../../../test/mocks/datasource_srv'; import { buildQueryTransaction, - DEFAULT_RANGE, hasNonEmptyQuery, - parseUrlState, refreshIntervalToSortOrder, updateHistory, getExploreUrl, @@ -42,67 +40,6 @@ jest.mock('@grafana/runtime', () => ({ })); describe('state functions', () => { - describe('parseUrlState', () => { - it('returns default state on empty string', () => { - expect(parseUrlState('')).toMatchObject({ - datasource: null, - queries: [], - range: DEFAULT_RANGE, - }); - }); - - it('returns a valid Explore state from URL parameter', () => { - const paramValue = '{"datasource":"Local","queries":[{"expr":"metric"}],"range":{"from":"now-1h","to":"now"}}'; - expect(parseUrlState(paramValue)).toMatchObject({ - datasource: 'Local', - queries: [{ expr: 'metric' }], - range: { - from: 'now-1h', - to: 'now', - }, - }); - }); - - it('returns a valid Explore state from a compact URL parameter', () => { - const paramValue = '["now-1h","now","Local",{"expr":"metric"},{"ui":[true,true,true,"none"]}]'; - expect(parseUrlState(paramValue)).toMatchObject({ - datasource: 'Local', - queries: [{ expr: 'metric' }], - range: { - from: 'now-1h', - to: 'now', - }, - }); - }); - - it('should not return a query for mode in the url', () => { - // Previous versions of Grafana included "Explore mode" in the URL; this should not be treated as a query. - const paramValue = - '["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"mode":"Metrics"},{"ui":[true,true,true,"none"]}]'; - expect(parseUrlState(paramValue)).toMatchObject({ - datasource: 'x-ray-datasource', - queries: [{ queryType: 'getTraceSummaries' }], - range: { - from: 'now-1h', - to: 'now', - }, - }); - }); - - it('should return queries if queryType is present in the url', () => { - const paramValue = - '["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"ui":[true,true,true,"none"]}]'; - expect(parseUrlState(paramValue)).toMatchObject({ - datasource: 'x-ray-datasource', - queries: [{ queryType: 'getTraceSummaries' }], - range: { - from: 'now-1h', - to: 'now', - }, - }); - }); - }); - describe('serializeStateToUrlParam', () => { it('returns url parameter value for a state object', () => { const state = { @@ -130,61 +67,6 @@ describe('state functions', () => { ); }); }); - - describe('interplay', () => { - it('can parse the serialized state into the original state', () => { - const state = { - ...DEFAULT_EXPLORE_STATE, - datasource: 'foo', - queries: [ - { - expr: 'metric{test="a/b"}', - refId: 'A', - }, - { - expr: 'super{foo="x/z"}', - refId: 'B', - }, - ], - range: { - from: 'now - 5h', - to: 'now', - }, - }; - const serialized = serializeStateToUrlParam(state); - const parsed = parseUrlState(serialized); - expect(state).toMatchObject(parsed); - }); - - it('can parse serialized panelsState into the original state', () => { - const state = { - ...DEFAULT_EXPLORE_STATE, - datasource: 'foo', - queries: [ - { - expr: 'metric{test="a/b"}', - refId: 'A', - }, - { - expr: 'super{foo="x/z"}', - refId: 'B', - }, - ], - range: { - from: 'now - 5h', - to: 'now', - }, - panelsState: { - trace: { - spanId: 'abcdef', - }, - }, - }; - const serialized = serializeStateToUrlParam(state); - const parsed = parseUrlState(serialized); - expect(state).toMatchObject(parsed); - }); - }); }); describe('getExploreUrl', () => { @@ -304,21 +186,21 @@ describe('when buildQueryTransaction', () => { const queries = [{ refId: 'A' }]; const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; - const transaction = buildQueryTransaction(ExploreId.left, queries, queryOptions, range, false); + const transaction = buildQueryTransaction('left', queries, queryOptions, range, false); expect(transaction.request.intervalMs).toEqual(60000); }); it('it should calculate interval taking minInterval into account', () => { const queries = [{ refId: 'A' }]; const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; const range = { from: dateTime().subtract(1, 'm'), to: dateTime(), raw: { from: '1h', to: '1h' } }; - const transaction = buildQueryTransaction(ExploreId.left, queries, queryOptions, range, false); + const transaction = buildQueryTransaction('left', queries, queryOptions, range, false); expect(transaction.request.intervalMs).toEqual(15000); }); it('it should calculate interval taking maxDataPoints into account', () => { const queries = [{ refId: 'A' }]; const queryOptions = { maxDataPoints: 10, minInterval: '15s' }; const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; - const transaction = buildQueryTransaction(ExploreId.left, queries, queryOptions, range, false); + const transaction = buildQueryTransaction('left', queries, queryOptions, range, false); expect(transaction.request.interval).toEqual('2h'); }); }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 942135b9ef3..9758216cd17 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -1,3 +1,4 @@ +import { nanoid } from '@reduxjs/toolkit'; import { omit } from 'lodash'; import { Unsubscribable } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; @@ -26,17 +27,12 @@ import store from 'app/core/store'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { PanelModel } from 'app/features/dashboard/state'; import { ExpressionDatasourceUID } from 'app/features/expressions/types'; -import { ExploreId, QueryOptions, QueryTransaction } from 'app/types/explore'; +import { QueryOptions, QueryTransaction } from 'app/types/explore'; import { config } from '../config'; import { getNextRefIdChar } from './query'; -export const DEFAULT_RANGE = { - from: 'now-1h', - to: 'now', -}; - export const DEFAULT_UI_STATE = { dedupStrategy: LogsDedupStrategy.none, }; @@ -58,6 +54,10 @@ export interface GetExploreUrlArguments { timeSrv: TimeSrv; } +export function generateExploreId() { + return nanoid(3); +} + /** * Returns an Explore-URL that contains a panel's queries and the dashboard time range. */ @@ -98,28 +98,26 @@ export async function getExploreUrl(args: GetExploreUrlArguments): Promise DataQuery = ({ key, ...rest }) => rest; -const isSegment = (segment: { [key: string]: string }, ...props: string[]) => - props.some((prop) => segment.hasOwnProperty(prop)); - -enum ParseUrlStateIndex { - RangeFrom = 0, - RangeTo = 1, - Datasource = 2, - SegmentsStart = 3, -} - export const safeParseJson = (text?: string): any | undefined => { if (!text) { return; @@ -208,40 +196,6 @@ export const safeStringifyValue = (value: unknown, space?: number) => { return ''; }; -export function parseUrlState(initial: string | undefined): ExploreUrlState { - const parsed = safeParseJson(initial); - const errorResult: any = { - datasource: null, - queries: [], - range: DEFAULT_RANGE, - mode: null, - }; - - if (!parsed) { - return errorResult; - } - - if (!Array.isArray(parsed)) { - return { queries: [], range: DEFAULT_RANGE, ...parsed }; - } - - if (parsed.length <= ParseUrlStateIndex.SegmentsStart) { - console.error('Error parsing compact URL state for Explore.'); - return errorResult; - } - - const range = { - from: parsed[ParseUrlStateIndex.RangeFrom], - to: parsed[ParseUrlStateIndex.RangeTo], - }; - const datasource = parsed[ParseUrlStateIndex.Datasource]; - const parsedSegments = parsed.slice(ParseUrlStateIndex.SegmentsStart); - const queries = parsedSegments.filter((segment) => !isSegment(segment, 'ui', 'mode', '__panelsState')); - - const panelsState = parsedSegments.find((segment) => isSegment(segment, '__panelsState'))?.__panelsState; - return { datasource, queries, range, panelsState }; -} - export function generateKey(index = 0): string { return `Q-${uuidv4()}-${index}`; } diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index 9c0b4be0d15..5679f1ffee1 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -134,7 +134,6 @@ export const createUrlFromRichHistory = (query: RichHistoryQuery) => { range: { from: 'now-1h', to: 'now' }, datasource: query.datasourceName, queries: query.queries, - context: 'explore', }; const serializedState = serializeStateToUrlParam(exploreState); diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx index b9fc2ec4447..8dbb8cbe42b 100644 --- a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx +++ b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx @@ -8,7 +8,7 @@ import { Alert, Button, Field, InputControl, Modal, RadioButtonGroup } from '@gr import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { contextSrv } from 'app/core/services/context_srv'; import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard'; -import { ExploreId, AccessControlAction, useSelector } from 'app/types'; +import { AccessControlAction, useSelector } from 'app/types'; import { getExploreItemSelector } from '../state/selectors'; @@ -57,7 +57,7 @@ interface SubmissionError { interface Props { onClose: () => void; - exploreId: ExploreId; + exploreId: string; } export const AddToDashboardModal = ({ onClose, exploreId }: Props) => { diff --git a/public/app/features/explore/AddToDashboard/index.test.tsx b/public/app/features/explore/AddToDashboard/index.test.tsx index 99b96961d6b..0ed31bc9380 100644 --- a/public/app/features/explore/AddToDashboard/index.test.tsx +++ b/public/app/features/explore/AddToDashboard/index.test.tsx @@ -11,7 +11,7 @@ import { Echo } from 'app/core/services/echo/Echo'; import * as initDashboard from 'app/features/dashboard/state/initDashboard'; import { DashboardSearchItemType } from 'app/features/search/types'; import { configureStore } from 'app/store/configureStore'; -import { ExploreId, ExploreState } from 'app/types'; +import { ExploreState } from 'app/types'; import { createEmptyQueryResponse } from '../state/utils'; @@ -52,7 +52,7 @@ describe('AddToDashboardButton', () => { }); it('Is disabled if explore pane has no queries', async () => { - setup(, []); + setup(, []); const button = await screen.findByRole('button', { name: /add to dashboard/i }); expect(button).toBeDisabled(); @@ -81,7 +81,7 @@ describe('AddToDashboardButton', () => { }); it('Opens and closes the modal correctly', async () => { - setup(); + setup(); await openModal(); @@ -96,7 +96,7 @@ describe('AddToDashboardButton', () => { const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); const pushSpy = jest.spyOn(locationService, 'push'); - setup(); + setup(); await openModal(); @@ -115,7 +115,7 @@ describe('AddToDashboardButton', () => { const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); const pushSpy = jest.spyOn(locationService, 'push'); - setup(); + setup(); await openModal(); @@ -134,7 +134,7 @@ describe('AddToDashboardButton', () => { // @ts-expect-error global.open should return a Window, but is not implemented in js-dom. const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); - setup(); + setup(); await openModal(); @@ -148,7 +148,7 @@ describe('AddToDashboardButton', () => { it('Navigates to the new dashboard', async () => { const pushSpy = jest.spyOn(locationService, 'push'); - setup(); + setup(); await openModal(); @@ -165,7 +165,7 @@ describe('AddToDashboardButton', () => { describe('Save to existing dashboard', () => { it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => { - setup(); + setup(); await openModal(); @@ -178,7 +178,7 @@ describe('AddToDashboardButton', () => { it('Does not submit if no dashboard is selected', async () => { locationService.push = jest.fn(); - setup(); + setup(); await openModal(); @@ -211,7 +211,7 @@ describe('AddToDashboardButton', () => { }, ]); - setup(); + setup(); await openModal(); @@ -252,7 +252,7 @@ describe('AddToDashboardButton', () => { }, ]); - setup(); + setup(); await openModal(); @@ -290,7 +290,7 @@ describe('AddToDashboardButton', () => { return true; } }); - setup(); + setup(); await openModal('Add panel to existing dashboard'); expect(screen.queryByRole('radio')).not.toBeInTheDocument(); }); @@ -303,7 +303,7 @@ describe('AddToDashboardButton', () => { return true; } }); - setup(); + setup(); await openModal('Add panel to new dashboard'); expect(screen.queryByRole('radio')).not.toBeInTheDocument(); }); @@ -322,7 +322,7 @@ describe('AddToDashboardButton', () => { jest.spyOn(global, 'open').mockReturnValue(null); const removeDashboardSpy = jest.spyOn(initDashboard, 'removeDashboardToFetchFromLocalStorage'); - setup(); + setup(); await openModal(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); @@ -341,7 +341,7 @@ describe('AddToDashboardButton', () => { throw 'SOME ERROR'; }); - setup(); + setup(); await openModal(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); @@ -367,7 +367,7 @@ describe('AddToDashboardButton', () => { }, ]); - setup(); + setup(); await openModal(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); @@ -391,7 +391,7 @@ describe('AddToDashboardButton', () => { it('Shows an error if an unknown error happens', async () => { jest.spyOn(api, 'setDashboardInLocalStorage').mockRejectedValue('SOME ERROR'); - setup(); + setup(); await openModal(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); diff --git a/public/app/features/explore/AddToDashboard/index.tsx b/public/app/features/explore/AddToDashboard/index.tsx index 6dabde1e230..3fe16b6a190 100644 --- a/public/app/features/explore/AddToDashboard/index.tsx +++ b/public/app/features/explore/AddToDashboard/index.tsx @@ -1,14 +1,14 @@ import React, { useState } from 'react'; import { ToolbarButton } from '@grafana/ui'; -import { ExploreId, useSelector } from 'app/types'; +import { useSelector } from 'app/types'; import { getExploreItemSelector } from '../state/selectors'; import { AddToDashboardModal } from './AddToDashboardModal'; interface Props { - exploreId: ExploreId; + exploreId: string; } export const AddToDashboard = ({ exploreId }: Props) => { diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 308178b821a..bbeb209db17 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -6,7 +6,6 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { configureStore } from 'app/store/configureStore'; -import { ExploreId } from 'app/types'; import { Explore, Props } from './Explore'; import { initialExploreState } from './state/main'; @@ -61,7 +60,7 @@ const dummyProps: Props = { QueryEditorHelp: {}, }, } as DataSourceApi, - exploreId: ExploreId.left, + exploreId: 'left', loading: false, modifyQueries: jest.fn(), scanStart: jest.fn(), diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 681c3f2f3f8..12b8482527b 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -34,7 +34,6 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils'; import { StoreState } from 'app/types'; import { AbsoluteTimeEvent } from 'app/types/events'; -import { ExploreId } from 'app/types/explore'; import { getTimeZone } from '../profile/state/selectors'; @@ -98,7 +97,7 @@ const getStyles = (theme: GrafanaTheme2) => { }; export interface ExploreProps extends Themeable2 { - exploreId: ExploreId; + exploreId: string; theme: GrafanaTheme2; eventBus: EventBus; } diff --git a/public/app/features/explore/ExploreActions.tsx b/public/app/features/explore/ExploreActions.tsx index da0e0271476..04518fb4921 100644 --- a/public/app/features/explore/ExploreActions.tsx +++ b/public/app/features/explore/ExploreActions.tsx @@ -1,24 +1,22 @@ import { useRegisterActions, useKBar, Action, Priority } from 'kbar'; import { useEffect, useState } from 'react'; -import { ExploreId, useDispatch, useSelector } from 'app/types'; +import { useDispatch, useSelector } from 'app/types'; import { splitOpen, splitClose } from './state/main'; import { runQueries } from './state/query'; -import { isSplit } from './state/selectors'; +import { isSplit, selectPanes } from './state/selectors'; -interface Props { - exploreIdLeft: ExploreId; - exploreIdRight?: ExploreId; -} - -export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => { +// FIXME: this should use the new IDs +export const ExploreActions = () => { const [actions, setActions] = useState([]); const { query } = useKBar(); const dispatch = useDispatch(); + const panes = useSelector(selectPanes); const splitted = useSelector(isSplit); useEffect(() => { + const keys = Object.keys(panes); const exploreSection = { name: 'Explore', priority: Priority.HIGH + 1, @@ -32,18 +30,18 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => { name: 'Run query (left)', keywords: 'query left', perform: () => { - dispatch(runQueries({ exploreId: exploreIdLeft })); + dispatch(runQueries({ exploreId: keys[0] })); }, section: exploreSection, }); - if (exploreIdRight) { + if ([panes[1]]) { // we should always have the right exploreId if split actionsArr.push({ id: 'explore/run-query-right', name: 'Run query (right)', keywords: 'query right', perform: () => { - dispatch(runQueries({ exploreId: exploreIdRight })); + dispatch(runQueries({ exploreId: keys[1] })); }, section: exploreSection, }); @@ -52,7 +50,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => { name: 'Close split view left', keywords: 'split', perform: () => { - dispatch(splitClose(exploreIdLeft)); + dispatch(splitClose(keys[0])); }, section: exploreSection, }); @@ -61,7 +59,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => { name: 'Close split view right', keywords: 'split', perform: () => { - dispatch(splitClose(exploreIdRight)); + dispatch(splitClose(keys[1])); }, section: exploreSection, }); @@ -72,7 +70,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => { name: 'Run query', keywords: 'query', perform: () => { - dispatch(runQueries({ exploreId: exploreIdLeft })); + dispatch(runQueries({ exploreId: keys[0] })); }, section: exploreSection, }); @@ -87,7 +85,7 @@ export const ExploreActions = ({ exploreIdLeft, exploreIdRight }: Props) => { }); } setActions(actionsArr); - }, [exploreIdLeft, exploreIdRight, splitted, query, dispatch]); + }, [panes, splitted, query, dispatch]); useRegisterActions(!query ? [] : actions, [actions, query]); diff --git a/public/app/features/explore/ExplorePage.test.tsx b/public/app/features/explore/ExplorePage.test.tsx index 5b9eff30b1d..53f7b48afd6 100644 --- a/public/app/features/explore/ExplorePage.test.tsx +++ b/public/app/features/explore/ExplorePage.test.tsx @@ -4,8 +4,6 @@ import React, { ComponentProps } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { serializeStateToUrlParam } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { ExploreId } from 'app/types'; import { makeLogsQueryResponse } from './spec/helper/query'; import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup'; @@ -101,7 +99,7 @@ describe('ExplorePage', () => { orgId: '1', }; - const { datasources, location } = setupExplore({ urlParams }); + const { datasources } = setupExplore({ urlParams }); jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse()); jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeLogsQueryResponse()); @@ -119,9 +117,6 @@ describe('ExplorePage', () => { expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument(); expect(screen.getByText(`elastic Editor input: error`)).toBeInTheDocument(); - // We did not change the url - expect(location.getSearchObject()).toEqual(expect.objectContaining(urlParams)); - // We called the data source query method once expect(datasources.loki.query).toBeCalledTimes(1); expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({ @@ -155,31 +150,6 @@ describe('ExplorePage', () => { }); }); - it('Reacts to URL changes and opens a pane if an entry is pushed to history', async () => { - const urlParams = { - left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), - }; - const { datasources, location } = setupExplore({ urlParams }); - jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse()); - jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse()); - - await waitFor(() => { - expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument(); - }); - - act(() => { - location.partial({ - left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), - right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'error' }]), - }); - }); - - await waitFor(() => { - expect(screen.getByText(`loki Editor input: { label="value"}`)).toBeInTheDocument(); - expect(screen.getByText(`elastic Editor input: error`)).toBeInTheDocument(); - }); - }); - it('handles opening split with split open func', async () => { const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), @@ -207,7 +177,7 @@ describe('ExplorePage', () => { const splitButton = await screen.findByText(/split/i); await userEvent.click(splitButton); - await waitForExplore(ExploreId.left); + await waitForExplore('left'); expect(await screen.findAllByLabelText('Widen pane')).toHaveLength(2); expect(screen.queryByLabelText('Narrow pane')).not.toBeInTheDocument(); @@ -226,276 +196,4 @@ describe('ExplorePage', () => { expect(await screen.findAllByLabelText('Narrow pane')).toHaveLength(1); }); }); - - describe('Handles document title changes', () => { - it('changes the document title of the explore page to include the datasource in use', async () => { - const urlParams = { - left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), - }; - const { datasources } = setupExplore({ urlParams }); - jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse()); - // This is mainly to wait for render so that the left pane state is initialized as that is needed for the title - // to include the datasource - await screen.findByText(`loki Editor input: { label="value"}`); - - await waitFor(() => expect(document.title).toEqual('Explore - loki - Grafana')); - }); - - it('changes the document title to include the two datasources in use in split view mode', async () => { - const urlParams = { - left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), - }; - const { datasources, store } = setupExplore({ urlParams }); - jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse()); - jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse()); - - // This is mainly to wait for render so that the left pane state is initialized as that is needed for splitOpen - // to work - await screen.findByText(`loki Editor input: { label="value"}`); - - act(() => { - store.dispatch(mainState.splitOpen({ datasourceUid: 'elastic', query: { expr: 'error', refId: 'A' } })); - }); - await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana')); - }); - }); - - describe('Handles different URL datasource redirects', () => { - describe('exploreMixedDatasource on', () => { - beforeAll(() => { - config.featureToggles.exploreMixedDatasource = true; - }); - - describe('When root datasource is not specified in the URL', () => { - it('Redirects to default datasource', async () => { - const { location } = setupExplore({ mixedEnabled: true }); - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - - expect(urlParams).toBe( - 'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - expect(location.getHistory()).toHaveLength(1); - }); - - it('Redirects to last used datasource when available', async () => { - const { location } = setupExplore({ - prevUsedDatasource: { orgId: 1, datasource: 'elastic-uid' }, - mixedEnabled: true, - }); - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - expect(urlParams).toBe( - 'left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - expect(location.getHistory()).toHaveLength(1); - }); - - it("Redirects to first query's datasource", async () => { - const { location } = setupExplore({ - urlParams: { - left: '{"queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}', - }, - prevUsedDatasource: { orgId: 1, datasource: 'elastic' }, - mixedEnabled: true, - }); - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - expect(urlParams).toBe( - 'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - expect(location.getHistory()).toHaveLength(1); - }); - }); - - describe('When root datasource is specified in the URL', () => { - it('Uses the datasource in the URL', async () => { - const { location } = setupExplore({ - urlParams: { - left: '{"datasource":"elastic-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}', - }, - prevUsedDatasource: { orgId: 1, datasource: 'elastic' }, - mixedEnabled: true, - }); - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - expect(urlParams).toBe( - 'left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - - expect(location.getHistory()).toHaveLength(1); - }); - - it('Filters out queries not using the root datasource', async () => { - const { location } = setupExplore({ - urlParams: { - left: '{"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}', - }, - prevUsedDatasource: { orgId: 1, datasource: 'elastic' }, - mixedEnabled: true, - }); - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - expect(urlParams).toBe( - 'left={"datasource":"elastic-uid","queries":[{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - }); - - it('Fallbacks to last used datasource if root datasource does not exist', async () => { - const { location } = setupExplore({ - urlParams: { left: '{"datasource":"NON-EXISTENT","range":{"from":"now-1h","to":"now"}}' }, - prevUsedDatasource: { orgId: 1, datasource: 'elastic' }, - mixedEnabled: true, - }); - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - expect(urlParams).toBe( - 'left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - }); - - it('Fallbacks to default datasource if root datasource does not exist and last used datasource does not exist', async () => { - const { location } = setupExplore({ - urlParams: { left: '{"datasource":"NON-EXISTENT","range":{"from":"now-1h","to":"now"}}' }, - prevUsedDatasource: { orgId: 1, datasource: 'I DO NOT EXIST' }, - mixedEnabled: true, - }); - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - expect(urlParams).toBe( - 'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - }); - - it('Fallbacks to default datasource if root datasource does not exist there is no last used datasource', async () => { - const { location } = setupExplore({ - urlParams: { left: '{"datasource":"NON-EXISTENT","range":{"from":"now-1h","to":"now"}}' }, - mixedEnabled: true, - }); - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - expect(urlParams).toBe( - 'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - }); - }); - - it('Queries using nonexisting datasources gets removed', async () => { - const { location } = setupExplore({ - urlParams: { - left: '{"datasource":"-- Mixed --","queries":[{"refId":"A","datasource":{"type":"NON-EXISTENT","uid":"NON-EXISTENT"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}', - }, - prevUsedDatasource: { orgId: 1, datasource: 'elastic' }, - mixedEnabled: true, - }); - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - expect(urlParams).toBe( - 'left={"datasource":"--+Mixed+--","queries":[{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - }); - - it('Only keeps queries using root datasource', async () => { - const { location } = setupExplore({ - urlParams: { - left: '{"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}', - }, - prevUsedDatasource: { orgId: 1, datasource: 'elastic' }, - mixedEnabled: true, - }); - - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - // because there are no import/export queries in our mock datasources, only the first one remains - - expect(urlParams).toBe( - 'left={"datasource":"elastic-uid","queries":[{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - }); - }); - }); - - describe('exploreMixedDatasource off', () => { - beforeAll(() => { - config.featureToggles.exploreMixedDatasource = false; - }); - - it('Redirects to the first query datasource if the root is mixed', async () => { - const { location } = setupExplore({ - urlParams: { - left: '{"datasource":"-- Mixed --","queries":[{"refId":"A","datasource":{"type":"logs","uid":"elastic-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}', - }, - mixedEnabled: false, - }); - - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - - expect(urlParams).toBe( - 'left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - }); - - it('Redirects to the default datasource if the root is mixed and there are no queries', async () => { - const { location } = setupExplore({ - urlParams: { - left: '{"datasource":"-- Mixed --","range":{"from":"now-1h","to":"now"}}', - }, - mixedEnabled: false, - }); - - await waitForExplore(); - - await waitFor(() => { - const urlParams = decodeURIComponent(location.getSearch().toString()); - - expect(urlParams).toBe( - 'left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}&orgId=1' - ); - }); - }); - }); - - it('removes `from` and `to` parameters from url when first mounted', async () => { - const { location } = setupExplore({ urlParams: { from: '1', to: '2' } }); - await waitForExplore(); - - await waitFor(() => { - expect(location.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' })); - expect(location.getSearchObject()).toEqual(expect.objectContaining({ orgId: '1' })); - }); - }); }); diff --git a/public/app/features/explore/ExplorePage.tsx b/public/app/features/explore/ExplorePage.tsx index 8888e86faa9..58aba32eaab 100644 --- a/public/app/features/explore/ExplorePage.tsx +++ b/public/app/features/explore/ExplorePage.tsx @@ -9,17 +9,16 @@ import { useGrafana } from 'app/core/context/GrafanaContext'; import { useNavModel } from 'app/core/hooks/useNavModel'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { useDispatch, useSelector } from 'app/types'; -import { ExploreId, ExploreQueryParams } from 'app/types/explore'; +import { ExploreQueryParams } from 'app/types/explore'; import { ExploreActions } from './ExploreActions'; import { ExplorePaneContainer } from './ExplorePaneContainer'; import { useExploreCorrelations } from './hooks/useExploreCorrelations'; import { useExplorePageTitle } from './hooks/useExplorePageTitle'; import { useStateSync } from './hooks/useStateSync'; -import { useStopQueries } from './hooks/useStopQueries'; import { useTimeSrvFix } from './hooks/useTimeSrvFix'; import { splitSizeUpdateAction } from './state/main'; -import { selectOrderedExplorePanes } from './state/selectors'; +import { isSplit, selectPanesEntries } from './state/selectors'; const styles = { pageScrollbarWrapper: css` @@ -32,10 +31,14 @@ const styles = { }; export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) { - useStopQueries(); useTimeSrvFix(); useStateSync(props.queryParams); - useExplorePageTitle(); + // We want to set the title according to the URL and not to the state because the URL itself may lag + // (due to how useStateSync above works) by a few milliseconds. + // When a URL is pushed to the history, the browser also saves the title of the page and + // if we were to update the URL on state change, the title would not match the URL. + // Ultimately the URL is the single source of truth from which state is derived, the page title is not different + useExplorePageTitle(props.queryParams); useExploreCorrelations(); const dispatch = useDispatch(); const { keybindings, chrome } = useGrafana(); @@ -45,7 +48,8 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor const minWidth = 200; const exploreState = useSelector((state) => state.explore); - const panes = useSelector(selectOrderedExplorePanes); + const panes = useSelector(selectPanesEntries); + const hasSplit = useSelector(isSplit); useEffect(() => { //This is needed for breadcrumbs and topnav. @@ -65,7 +69,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor } else { dispatch( splitSizeUpdateAction({ - largerExploreId: size > evenSplitWidth ? ExploreId.right : ExploreId.left, + largerExploreId: size > evenSplitWidth ? panes[1][0] : panes[0][0], }) ); } @@ -73,11 +77,10 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor setRightPaneWidthRatio(size / windowWidth); }; - const hasSplit = Object.entries(panes).length > 1; let widthCalc = 0; if (hasSplit) { if (!exploreState.evenSplitPanes && exploreState.maxedExploreId) { - widthCalc = exploreState.maxedExploreId === ExploreId.right ? windowWidth - minWidth : minWidth; + widthCalc = exploreState.maxedExploreId === panes[1][0] ? windowWidth - minWidth : minWidth; } else if (exploreState.evenSplitPanes) { widthCalc = Math.floor(windowWidth / 2); } else if (rightPaneWidthRatio !== undefined) { @@ -87,7 +90,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor return (
- + { - if (size) { - updateSplitSize(size); - } - }} + onDragFinished={(size) => size && updateSplitSize(size)} > - {Object.keys(panes).map((exploreId) => { + {panes.map(([exploreId]) => { return ( - + ); })} diff --git a/public/app/features/explore/ExplorePaneContainer.tsx b/public/app/features/explore/ExplorePaneContainer.tsx index 42e8a36acdf..ddee6ba8cdd 100644 --- a/public/app/features/explore/ExplorePaneContainer.tsx +++ b/public/app/features/explore/ExplorePaneContainer.tsx @@ -1,14 +1,15 @@ import { css } from '@emotion/css'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { connect } from 'react-redux'; import { EventBusSrv, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { useStyles2 } from '@grafana/ui'; -import { StoreState } from 'app/types'; -import { ExploreId } from 'app/types/explore'; +import { stopQueryState } from 'app/core/utils/explore'; +import { StoreState, useSelector } from 'app/types'; import Explore from './Explore'; +import { getExploreItemSelector } from './state/selectors'; const getStyles = (theme: GrafanaTheme2) => { return { @@ -26,7 +27,7 @@ const getStyles = (theme: GrafanaTheme2) => { }; interface Props { - exploreId: ExploreId; + exploreId: string; } /* @@ -39,6 +40,7 @@ interface Props { You can read more about this issue here: https://react-redux.js.org/api/hooks#stale-props-and-zombie-children */ function ExplorePaneContainerUnconnected({ exploreId }: Props) { + useStopQueries(exploreId); const styles = useStyles2(getStyles); const eventBus = useRef(new EventBusSrv()); const ref = useRef(null); @@ -64,3 +66,15 @@ function mapStateToProps(state: StoreState, props: Props) { const connector = connect(mapStateToProps); export const ExplorePaneContainer = connector(ExplorePaneContainerUnconnected); + +function useStopQueries(exploreId: string) { + const paneSelector = useMemo(() => getExploreItemSelector(exploreId), [exploreId]); + const paneRef = useRef>(); + paneRef.current = useSelector(paneSelector); + + useEffect(() => { + return () => { + stopQueryState(paneRef.current?.querySubscription); + }; + }, []); +} diff --git a/public/app/features/explore/ExploreQueryInspector.test.tsx b/public/app/features/explore/ExploreQueryInspector.test.tsx index 7efbe215981..c07c2898484 100644 --- a/public/app/features/explore/ExploreQueryInspector.test.tsx +++ b/public/app/features/explore/ExploreQueryInspector.test.tsx @@ -3,7 +3,6 @@ import React, { ComponentProps } from 'react'; import { Observable } from 'rxjs'; import { LoadingState, InternalTimeZones, getDefaultTimeRange } from '@grafana/data'; -import { ExploreId } from 'app/types'; import { ExploreQueryInspector } from './ExploreQueryInspector'; @@ -38,7 +37,7 @@ const setup = (propOverrides = {}) => { const props: ExploreQueryInspectorProps = { loading: false, width: 100, - exploreId: ExploreId.left, + exploreId: 'left', onClose: jest.fn(), timeZone: InternalTimeZones.utc, queryResponse: { diff --git a/public/app/features/explore/ExploreQueryInspector.tsx b/public/app/features/explore/ExploreQueryInspector.tsx index 0b50785ec74..5a34d588ad5 100644 --- a/public/app/features/explore/ExploreQueryInspector.tsx +++ b/public/app/features/explore/ExploreQueryInspector.tsx @@ -10,13 +10,13 @@ import { InspectErrorTab } from 'app/features/inspector/InspectErrorTab'; import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab'; import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab'; import { QueryInspector } from 'app/features/inspector/QueryInspector'; -import { StoreState, ExploreItemState, ExploreId } from 'app/types'; +import { StoreState, ExploreItemState } from 'app/types'; import { runQueries, selectIsWaitingForData } from './state/query'; interface DispatchProps { width: number; - exploreId: ExploreId; + exploreId: string; timeZone: TimeZone; onClose: () => void; } @@ -90,7 +90,7 @@ export function ExploreQueryInspector(props: Props) { ); } -function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { +function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) { const explore = state.explore; const item: ExploreItemState = explore.panes[exploreId]!; const { queryResponse } = item; diff --git a/public/app/features/explore/ExploreTimeControls.tsx b/public/app/features/explore/ExploreTimeControls.tsx index e7c987bd3f6..e5cfc67fe2c 100644 --- a/public/app/features/explore/ExploreTimeControls.tsx +++ b/public/app/features/explore/ExploreTimeControls.tsx @@ -4,12 +4,11 @@ import { TimeRange, TimeZone, RawTimeRange, dateTimeForTimeZone, dateMath } from import { reportInteraction } from '@grafana/runtime'; import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory'; import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; -import { ExploreId } from 'app/types'; import { TimeSyncButton } from './TimeSyncButton'; export interface Props { - exploreId: ExploreId; + exploreId: string; hideText?: boolean; range: TimeRange; timeZone: TimeZone; diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index ce831e79693..0fab403a8d0 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -11,7 +11,6 @@ import { contextSrv } from 'app/core/core'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { AccessControlAction } from 'app/types'; -import { ExploreId } from 'app/types/explore'; import { StoreState, useDispatch, useSelector } from 'app/types/store'; import { DashNavButton } from '../dashboard/components/DashNav/DashNavButton'; @@ -24,7 +23,7 @@ import { LiveTailButton } from './LiveTailButton'; import { changeDatasource } from './state/datasource'; import { splitClose, splitOpen, maximizePaneAction, evenPaneResizeAction } from './state/main'; import { cancelQueries, runQueries, selectIsWaitingForData } from './state/query'; -import { isSplit } from './state/selectors'; +import { isSplit, selectPanesEntries } from './state/selectors'; import { syncTimes, changeRefreshInterval } from './state/time'; import { LiveTailControls } from './useLiveTailControls'; @@ -39,7 +38,7 @@ const rotateIcon = css({ }); interface Props { - exploreId: ExploreId; + exploreId: string; onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void; topOfViewRef: RefObject; } @@ -64,9 +63,11 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props) (state) => state.explore.panes[exploreId]!.containerWidth < (splitted ? 700 : 800) ); + const panes = useSelector(selectPanesEntries); + const shouldRotateSplitIcon = useMemo( - () => (exploreId === 'left' && isLargerPane) || (exploreId === 'right' && !isLargerPane), - [isLargerPane, exploreId] + () => (exploreId === panes[0][0] && isLargerPane) || (exploreId === panes[1]?.[0] && !isLargerPane), + [isLargerPane, exploreId, panes] ); const onCopyShortLink = () => { diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx index 7c1d813aa25..ac36208c8c0 100644 --- a/public/app/features/explore/Logs/Logs.test.tsx +++ b/public/app/features/explore/Logs/Logs.test.tsx @@ -11,7 +11,6 @@ import { MutableDataFrame, toUtc, } from '@grafana/data'; -import { ExploreId } from 'app/types'; import { Logs } from './Logs'; @@ -46,7 +45,7 @@ jest.mock('app/store/store', () => ({ const changePanelState = jest.fn(); jest.mock('../state/explorePane', () => ({ ...jest.requireActual('../state/explorePane'), - changePanelState: (exploreId: ExploreId, panel: 'logs', panelState: {} | ExploreLogsPanelState) => { + changePanelState: (exploreId: string, panel: 'logs', panelState: {} | ExploreLogsPanelState) => { return changePanelState(exploreId, panel, panelState); }, })); @@ -85,7 +84,7 @@ describe('Logs', () => { return ( undefined} logsVolumeEnabled={true} onSetLogsVolumeEnabled={() => null} @@ -141,7 +140,7 @@ describe('Logs', () => { const scanningStarted = jest.fn(); render( undefined} logsVolumeEnabled={true} onSetLogsVolumeEnabled={() => null} @@ -178,7 +177,7 @@ describe('Logs', () => { it('should render a stop scanning button', () => { render( undefined} logsVolumeEnabled={true} onSetLogsVolumeEnabled={() => null} @@ -218,7 +217,7 @@ describe('Logs', () => { render( undefined} logsVolumeEnabled={true} onSetLogsVolumeEnabled={() => null} @@ -270,10 +269,10 @@ describe('Logs', () => { const panelState = { logs: { id: '1' } }; const { rerender } = setup({ loading: false, panelState }); - rerender(getComponent({ loading: true, exploreId: ExploreId.right, panelState })); - rerender(getComponent({ loading: false, exploreId: ExploreId.right, panelState })); + rerender(getComponent({ loading: true, exploreId: 'right', panelState })); + rerender(getComponent({ loading: false, exploreId: 'right', panelState })); - expect(changePanelState).toHaveBeenCalledWith(ExploreId.right, 'logs', { logs: {} }); + expect(changePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} }); }); it('should scroll the scrollElement into view if rows contain id', () => { diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index e76b01064a9..81369efe2c1 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -46,7 +46,6 @@ import { dedupLogRows, filterLogLevels } from 'app/core/logsModel'; import store from 'app/core/store'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { getState, dispatch } from 'app/store/store'; -import { ExploreId } from 'app/types/explore'; import { LogRows } from '../../logs/components/LogRows'; import { LogRowContextModal } from '../../logs/components/log-context/LogRowContextModal'; @@ -73,7 +72,7 @@ interface Props extends Themeable2 { timeZone: TimeZone; scanning?: boolean; scanRange?: RawTimeRange; - exploreId: ExploreId; + exploreId: string; datasourceType?: string; logsVolumeEnabled: boolean; logsVolumeData: DataQueryResponse | undefined; diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index 5bf3791770a..e80573baefb 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -21,7 +21,7 @@ import { import { DataQuery } from '@grafana/schema'; import { Collapse } from '@grafana/ui'; import { StoreState } from 'app/types'; -import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { ExploreItemState } from 'app/types/explore'; import { getTimeZone } from '../../profile/state/selectors'; import { @@ -41,7 +41,7 @@ import { LogsCrossFadeTransition } from './utils/LogsCrossFadeTransition'; interface LogsContainerProps extends PropsFromRedux { width: number; - exploreId: ExploreId; + exploreId: string; scanRange?: RawTimeRange; syncedTimes: boolean; loadingState: LoadingState; @@ -218,7 +218,7 @@ class LogsContainer extends PureComponent { } } -function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { +function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) { const explore = state.explore; const item: ExploreItemState = explore.panes[exploreId]!; const { diff --git a/public/app/features/explore/NodeGraph/NodeGraphContainer.test.tsx b/public/app/features/explore/NodeGraph/NodeGraphContainer.test.tsx index d37d63a513c..7fdc2ce78b2 100644 --- a/public/app/features/explore/NodeGraph/NodeGraphContainer.test.tsx +++ b/public/app/features/explore/NodeGraph/NodeGraphContainer.test.tsx @@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { getDefaultTimeRange, MutableDataFrame } from '@grafana/data'; -import { ExploreId } from 'app/types'; import { UnconnectedNodeGraphContainer } from './NodeGraphContainer'; @@ -11,7 +10,7 @@ describe('NodeGraphContainer', () => { const { container } = render( {}} withTraceView={true} @@ -27,7 +26,7 @@ describe('NodeGraphContainer', () => { const { container } = render( {}} datasourceType={''} diff --git a/public/app/features/explore/NodeGraph/NodeGraphContainer.tsx b/public/app/features/explore/NodeGraph/NodeGraphContainer.tsx index afd7a9981a3..4f31a960219 100644 --- a/public/app/features/explore/NodeGraph/NodeGraphContainer.tsx +++ b/public/app/features/explore/NodeGraph/NodeGraphContainer.tsx @@ -9,7 +9,7 @@ import { Collapse, useStyles2, useTheme2 } from '@grafana/ui'; import { NodeGraph } from '../../../plugins/panel/nodeGraph'; import { useCategorizeFrames } from '../../../plugins/panel/nodeGraph/useCategorizeFrames'; -import { ExploreId, StoreState } from '../../../types'; +import { StoreState } from '../../../types'; import { useLinks } from '../utils/links'; const getStyles = (theme: GrafanaTheme2) => ({ @@ -23,7 +23,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ interface OwnProps { // Edges and Nodes are separate frames dataFrames: DataFrame[]; - exploreId: ExploreId; + exploreId: string; // When showing the node graph together with trace view we do some changes so it works better. withTraceView?: boolean; datasourceType: string; diff --git a/public/app/features/explore/QueryRows.test.tsx b/public/app/features/explore/QueryRows.test.tsx index c04019c4283..95783ae7ecd 100644 --- a/public/app/features/explore/QueryRows.test.tsx +++ b/public/app/features/explore/QueryRows.test.tsx @@ -5,7 +5,7 @@ import { Provider } from 'react-redux'; import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { configureStore } from 'app/store/configureStore'; -import { ExploreId, ExploreState } from 'app/types'; +import { ExploreState } from 'app/types'; import { UserState } from '../profile/state/reducers'; @@ -77,7 +77,7 @@ describe('Explore QueryRows', () => { render( - + ); diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 8a9124eed59..e90a09bb270 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -6,7 +6,6 @@ import { reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { getNextRefIdChar } from 'app/core/utils/query'; import { useDispatch, useSelector } from 'app/types'; -import { ExploreId } from 'app/types/explore'; import { getDatasourceSrv } from '../plugins/datasource_srv'; import { QueryEditorRows } from '../query/components/QueryEditorRows'; @@ -15,10 +14,10 @@ import { changeQueries, runQueries } from './state/query'; import { getExploreItemSelector } from './state/selectors'; interface Props { - exploreId: ExploreId; + exploreId: string; } -const makeSelectors = (exploreId: ExploreId) => { +const makeSelectors = (exploreId: string) => { const exploreItemSelector = getExploreItemSelector(exploreId); return { getQueries: createSelector(exploreItemSelector, (s) => s!.queries), diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.test.tsx b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.test.tsx index d218394c2ca..a310592e37a 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.test.tsx +++ b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import React from 'react'; import { FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame } from '@grafana/data'; -import { ExploreId, TABLE_RESULTS_STYLE } from 'app/types'; +import { TABLE_RESULTS_STYLE } from 'app/types'; import { RawPrometheusContainer } from './RawPrometheusContainer'; @@ -52,7 +52,7 @@ const dataFrame = toDataFrame({ }); const defaultProps = { - exploreId: ExploreId.left, + exploreId: 'left', loading: false, width: 800, onCellFilterAdded: jest.fn(), diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx index f3a9e03b65b..a4b226c8578 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx +++ b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx @@ -8,7 +8,7 @@ import { Collapse, RadioButtonGroup, Table, AdHocFilterItem } from '@grafana/ui' import { config } from 'app/core/config'; import { PANEL_BORDER } from 'app/core/constants'; import { StoreState, TABLE_RESULTS_STYLE } from 'app/types'; -import { ExploreId, ExploreItemState, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore'; +import { ExploreItemState, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore'; import { MetaInfoText } from '../MetaInfoText'; import RawListContainer from '../PrometheusListView/RawListContainer'; @@ -17,7 +17,7 @@ import { getFieldLinksForExplore } from '../utils/links'; interface RawPrometheusContainerProps { ariaLabel?: string; - exploreId: ExploreId; + exploreId: string; width: number; timeZone: TimeZone; onCellFilterAdded?: (filter: AdHocFilterItem) => void; diff --git a/public/app/features/explore/ResponseErrorContainer.test.tsx b/public/app/features/explore/ResponseErrorContainer.test.tsx index 9098b5c61a6..80bf3e9fd46 100644 --- a/public/app/features/explore/ResponseErrorContainer.test.tsx +++ b/public/app/features/explore/ResponseErrorContainer.test.tsx @@ -4,7 +4,6 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { DataQueryError, LoadingState } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { ExploreId } from 'app/types'; import { configureStore } from '../../store/configureStore'; @@ -60,7 +59,7 @@ function setup(error: DataQueryError) { render( - + ); } diff --git a/public/app/features/explore/ResponseErrorContainer.tsx b/public/app/features/explore/ResponseErrorContainer.tsx index 6dfd2c227c4..8c84c75a5ee 100644 --- a/public/app/features/explore/ResponseErrorContainer.tsx +++ b/public/app/features/explore/ResponseErrorContainer.tsx @@ -3,12 +3,10 @@ import React from 'react'; import { LoadingState } from '@grafana/data'; import { useSelector } from 'app/types'; -import { ExploreId } from '../../types'; - import { ErrorContainer } from './ErrorContainer'; interface Props { - exploreId: ExploreId; + exploreId: string; } export function ResponseErrorContainer(props: Props) { const queryResponse = useSelector((state) => state.explore.panes[props.exploreId]!.queryResponse); diff --git a/public/app/features/explore/RichHistory/RichHistory.test.tsx b/public/app/features/explore/RichHistory/RichHistory.test.tsx index fa8755c7a3f..c14af607ea2 100644 --- a/public/app/features/explore/RichHistory/RichHistory.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.test.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { SortOrder } from 'app/core/utils/richHistory'; -import { ExploreId } from 'app/types'; import { RichHistory, RichHistoryProps, Tabs } from './RichHistory'; @@ -23,7 +22,7 @@ jest.mock('@grafana/runtime', () => ({ const setup = (propOverrides?: Partial) => { const props: RichHistoryProps = { theme: {} as GrafanaTheme2, - exploreId: ExploreId.left, + exploreId: 'left', height: 100, activeDatasourceInstance: 'Test datasource', richHistory: [], diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx index 5e2e549203d..7998e2cc4dc 100644 --- a/public/app/features/explore/RichHistory/RichHistory.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.tsx @@ -4,7 +4,7 @@ import React, { PureComponent } from 'react'; import { SelectableValue } from '@grafana/data'; import { Themeable2, TabbedContainer, TabConfig, withTheme2 } from '@grafana/ui'; import { SortOrder, RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory'; -import { RichHistoryQuery, ExploreId } from 'app/types/explore'; +import { RichHistoryQuery } from 'app/types/explore'; import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider'; @@ -32,14 +32,14 @@ export interface RichHistoryProps extends Themeable2 { richHistorySettings: RichHistorySettings; richHistorySearchFilters?: RichHistorySearchFilters; updateHistorySettings: (settings: RichHistorySettings) => void; - updateHistorySearchFilters: (exploreId: ExploreId, filters: RichHistorySearchFilters) => void; - loadRichHistory: (exploreId: ExploreId) => void; - loadMoreRichHistory: (exploreId: ExploreId) => void; - clearRichHistoryResults: (exploreId: ExploreId) => void; + updateHistorySearchFilters: (exploreId: string, filters: RichHistorySearchFilters) => void; + loadRichHistory: (exploreId: string) => void; + loadMoreRichHistory: (exploreId: string) => void; + clearRichHistoryResults: (exploreId: string) => void; deleteRichHistory: () => void; activeDatasourceInstance: string; firstTab: Tabs; - exploreId: ExploreId; + exploreId: string; height: number; onClose: () => void; } diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx index a5502a6134a..cc8a53189bc 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx @@ -6,7 +6,7 @@ import { DataSourceApi, DataSourceInstanceSettings, DataSourcePluginMeta } from import { DataQuery, DataSourceRef } from '@grafana/schema'; import appEvents from 'app/core/app_events'; import { MixedDatasource } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { ExploreId, RichHistoryQuery } from 'app/types'; +import { RichHistoryQuery } from 'app/types'; import { ShowConfirmModalEvent } from 'app/types/events'; import { RichHistoryCard, Props } from './RichHistoryCard'; @@ -120,7 +120,7 @@ const setup = (propOverrides?: Partial>) => { deleteHistoryItem: deleteRichHistoryMock, commentHistoryItem: jest.fn(), setQueries: jest.fn(), - exploreId: ExploreId.left, + exploreId: 'left', datasourceInstance: dsStore.loki, }; diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index ee34d3ebe58..6cb7d708d8e 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -19,9 +19,9 @@ import { setQueries } from 'app/features/explore/state/query'; import { dispatch } from 'app/store/store'; import { StoreState } from 'app/types'; import { ShowConfirmModalEvent } from 'app/types/events'; -import { RichHistoryQuery, ExploreId } from 'app/types/explore'; +import { RichHistoryQuery } from 'app/types/explore'; -function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { +function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) { const explore = state.explore; const { datasourceInstance } = explore.panes[exploreId]!; return { diff --git a/public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx b/public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx index 813752ade03..64fed881262 100644 --- a/public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx @@ -2,7 +2,6 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SortOrder } from 'app/core/utils/richHistory'; -import { ExploreId } from 'app/types'; import { Tabs } from './RichHistory'; import { RichHistoryContainer, Props } from './RichHistoryContainer'; @@ -22,7 +21,7 @@ jest.mock('@grafana/runtime', () => ({ const setup = (propOverrides?: Partial) => { const props: Props = { width: 500, - exploreId: ExploreId.left, + exploreId: 'left', activeDatasourceInstance: 'Test datasource', richHistory: [], firstTab: Tabs.RichHistory, diff --git a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx index f89e30757c0..e520ee1cb60 100644 --- a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx @@ -6,7 +6,6 @@ import { config, reportInteraction } from '@grafana/runtime'; import { useTheme2 } from '@grafana/ui'; // Types import { ExploreItemState, StoreState } from 'app/types'; -import { ExploreId } from 'app/types/explore'; // Components, enums import { ExploreDrawer } from '../ExploreDrawer'; @@ -24,7 +23,7 @@ import { RichHistory, Tabs } from './RichHistory'; //Actions -function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { +function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) { const explore = state.explore; const item: ExploreItemState = explore.panes[exploreId]!; const richHistorySearchFilters = item.richHistorySearchFilters; @@ -56,7 +55,7 @@ const connector = connect(mapStateToProps, mapDispatchToProps); interface OwnProps { width: number; - exploreId: ExploreId; + exploreId: string; onClose: () => void; } export type Props = ConnectedProps & OwnProps; diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx index 84f61458285..9bd74449c9f 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { SortOrder } from 'app/core/utils/richHistoryTypes'; -import { ExploreId } from 'app/types'; import { RichHistoryQueriesTab, RichHistoryQueriesTabProps } from './RichHistoryQueriesTab'; @@ -30,7 +29,7 @@ const setup = (propOverrides?: Partial) => { lastUsedDatasourceFilters: [], starredTabAsFirstTab: false, }, - exploreId: ExploreId.left, + exploreId: 'left', height: 100, }; diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx index 9b15130e466..36bf694d41b 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx @@ -12,7 +12,7 @@ import { RichHistorySearchFilters, RichHistorySettings, } from 'app/core/utils/richHistory'; -import { ExploreId, RichHistoryQuery } from 'app/types/explore'; +import { RichHistoryQuery } from 'app/types/explore'; import { getSortOrderOptions } from './RichHistory'; import RichHistoryCard from './RichHistoryCard'; @@ -27,7 +27,7 @@ export interface RichHistoryQueriesTabProps { loadMoreRichHistory: () => void; richHistorySettings: RichHistorySettings; richHistorySearchFilters?: RichHistorySearchFilters; - exploreId: ExploreId; + exploreId: string; height: number; } diff --git a/public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx b/public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx index d978e8e4348..56b6ab5d148 100644 --- a/public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx @@ -2,7 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { SortOrder } from 'app/core/utils/richHistory'; -import { ExploreId } from 'app/types'; import { RichHistoryStarredTab, RichHistoryStarredTabProps } from './RichHistoryStarredTab'; @@ -26,7 +25,7 @@ const setup = (propOverrides?: Partial) => { updateFilters: jest.fn(), loadMoreRichHistory: jest.fn(), clearRichHistoryResults: jest.fn(), - exploreId: ExploreId.left, + exploreId: 'left', richHistorySettings: { retentionPeriod: 7, starredTabAsFirstTab: false, diff --git a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx index bd46d3a4bf0..b492c8f838c 100644 --- a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx @@ -10,7 +10,7 @@ import { RichHistorySearchFilters, RichHistorySettings, } from 'app/core/utils/richHistory'; -import { RichHistoryQuery, ExploreId } from 'app/types/explore'; +import { RichHistoryQuery } from 'app/types/explore'; import { getSortOrderOptions } from './RichHistory'; import RichHistoryCard from './RichHistoryCard'; @@ -25,7 +25,7 @@ export interface RichHistoryStarredTabProps { loadMoreRichHistory: () => void; richHistorySearchFilters?: RichHistorySearchFilters; richHistorySettings: RichHistorySettings; - exploreId: ExploreId; + exploreId: string; } const getStyles = (theme: GrafanaTheme2) => { diff --git a/public/app/features/explore/Table/TableContainer.test.tsx b/public/app/features/explore/Table/TableContainer.test.tsx index a227a93f1f0..4fecc5cf6a2 100644 --- a/public/app/features/explore/Table/TableContainer.test.tsx +++ b/public/app/features/explore/Table/TableContainer.test.tsx @@ -2,7 +2,6 @@ import { render, screen, within } from '@testing-library/react'; import React from 'react'; import { DataFrame, FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame } from '@grafana/data'; -import { ExploreId } from 'app/types'; import { TableContainer } from './TableContainer'; @@ -48,7 +47,7 @@ const dataFrame = toDataFrame({ }); const defaultProps = { - exploreId: ExploreId.left, + exploreId: 'left', loading: false, width: 800, onCellFilterAdded: jest.fn(), diff --git a/public/app/features/explore/Table/TableContainer.tsx b/public/app/features/explore/Table/TableContainer.tsx index 9daaacce0b2..531189fc40b 100644 --- a/public/app/features/explore/Table/TableContainer.tsx +++ b/public/app/features/explore/Table/TableContainer.tsx @@ -5,7 +5,7 @@ import { ValueLinkConfig, applyFieldOverrides, TimeZone, SplitOpen, DataFrame, L import { Table, AdHocFilterItem, PanelChrome } from '@grafana/ui'; import { config } from 'app/core/config'; import { StoreState } from 'app/types'; -import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { ExploreItemState } from 'app/types/explore'; import { MetaInfoText } from '../MetaInfoText'; import { selectIsWaitingForData } from '../state/query'; @@ -13,7 +13,7 @@ import { getFieldLinksForExplore } from '../utils/links'; interface TableContainerProps { ariaLabel?: string; - exploreId: ExploreId; + exploreId: string; width: number; timeZone: TimeZone; onCellFilterAdded?: (filter: AdHocFilterItem) => void; diff --git a/public/app/features/explore/TraceView/TraceView.test.tsx b/public/app/features/explore/TraceView/TraceView.test.tsx index 6287620f49e..191f51fdf5e 100644 --- a/public/app/features/explore/TraceView/TraceView.test.tsx +++ b/public/app/features/explore/TraceView/TraceView.test.tsx @@ -5,7 +5,6 @@ import { Provider } from 'react-redux'; import { DataFrame, MutableDataFrame, getDefaultTimeRange, LoadingState } from '@grafana/data'; import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; -import { ExploreId } from 'app/types'; import { configureStore } from '../../../store/configureStore'; @@ -26,7 +25,7 @@ function getTraceView(frames: DataFrame[]) { return ( {}} traceProp={transformDataFrames(frames[0])!} diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 06c927fa5b1..8b6f5e07247 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -24,7 +24,6 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { getTimeZone } from 'app/features/profile/state/selectors'; import { TempoQuery } from 'app/plugins/datasource/tempo/types'; import { useDispatch, useSelector } from 'app/types'; -import { ExploreId } from 'app/types/explore'; import { changePanelState } from '../state/explorePane'; @@ -63,7 +62,7 @@ function noop(): {} { type Props = { dataFrames: DataFrame[]; splitOpenFn?: SplitOpen; - exploreId?: ExploreId; + exploreId?: string; scrollElement?: Element; scrollElementClass?: string; traceProp: Trace; @@ -251,7 +250,7 @@ export function TraceView(props: Props) { * @param options */ function useFocusSpanLink(options: { - exploreId: ExploreId; + exploreId: string; splitOpenFn: SplitOpen; refId?: string; datasource?: DataSourceApi; diff --git a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx index 367d1176956..118acbc24e5 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx @@ -5,7 +5,6 @@ import { Provider } from 'react-redux'; import { getDefaultTimeRange, LoadingState } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { ExploreId } from 'app/types'; import { configureStore } from '../../../store/configureStore'; @@ -31,7 +30,7 @@ function renderTraceViewContainer(frames = [frameOld]) { const { container, baseElement } = render( {}} queryResponse={mockPanelData} diff --git a/public/app/features/explore/TraceView/TraceViewContainer.tsx b/public/app/features/explore/TraceView/TraceViewContainer.tsx index 7572570cbf8..29668965cd3 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.tsx @@ -5,7 +5,6 @@ import { DataFrame, SplitOpen, PanelData, GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; import { StoreState, useSelector } from 'app/types'; -import { ExploreId } from 'app/types/explore'; import { TraceView } from './TraceView'; import TracePageSearchBar from './components/TracePageHeader/TracePageSearchBar'; @@ -15,7 +14,7 @@ import { transformDataFrames } from './utils/transform'; interface Props { dataFrames: DataFrame[]; splitOpenFn: SplitOpen; - exploreId: ExploreId; + exploreId: string; scrollElement?: Element; queryResponse: PanelData; topOfViewRef: RefObject; diff --git a/public/app/features/explore/hooks/useExplorePageTitle.test.ts b/public/app/features/explore/hooks/useExplorePageTitle.test.ts new file mode 100644 index 00000000000..338fb9d2558 --- /dev/null +++ b/public/app/features/explore/hooks/useExplorePageTitle.test.ts @@ -0,0 +1,96 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { TestProvider } from 'test/helpers/TestProvider'; + +import { setDataSourceSrv } from '@grafana/runtime'; +import { DataSourceRef } from '@grafana/schema'; + +import { makeDatasourceSetup } from '../spec/helper/setup'; + +import { useExplorePageTitle } from './useExplorePageTitle'; + +describe('useExplorePageTitle', () => { + it('changes the document title of the explore page to include the datasource in use', async () => { + const datasources = [ + makeDatasourceSetup({ name: 'loki', uid: 'loki-uid' }), + makeDatasourceSetup({ name: 'elastic', uid: 'elastic-uid' }), + ]; + + setDataSourceSrv({ + get(datasource?: string | DataSourceRef | null) { + let ds; + if (!datasource) { + ds = datasources[0]?.api; + } else { + ds = datasources.find((ds) => + typeof datasource === 'string' + ? ds.api.name === datasource || ds.api.uid === datasource + : ds.api.uid === datasource?.uid + )?.api; + } + + if (ds) { + return Promise.resolve(ds); + } + + return Promise.reject(); + }, + getInstanceSettings: jest.fn(), + getList: jest.fn(), + reload: jest.fn(), + }); + + renderHook(() => useExplorePageTitle({ panes: JSON.stringify({ a: { datasource: 'loki-uid' } }) }), { + wrapper: TestProvider, + }); + + await waitFor(() => { + expect(global.document.title).toEqual(expect.stringContaining('loki')); + expect(global.document.title).toEqual(expect.not.stringContaining('elastic')); + }); + }); + + it('changes the document title to include the two datasources in use in split view mode', async () => { + const datasources = [ + makeDatasourceSetup({ name: 'loki', uid: 'loki-uid' }), + makeDatasourceSetup({ name: 'elastic', uid: 'elastic-uid' }), + ]; + + setDataSourceSrv({ + get(datasource?: string | DataSourceRef | null) { + let ds; + if (!datasource) { + ds = datasources[0]?.api; + } else { + ds = datasources.find((ds) => + typeof datasource === 'string' + ? ds.api.name === datasource || ds.api.uid === datasource + : ds.api.uid === datasource?.uid + )?.api; + } + + if (ds) { + return Promise.resolve(ds); + } + + return Promise.reject(); + }, + getInstanceSettings: jest.fn(), + getList: jest.fn(), + reload: jest.fn(), + }); + + renderHook( + () => + useExplorePageTitle({ + panes: JSON.stringify({ a: { datasource: 'loki-uid' }, b: { datasource: 'elastic-uid' } }), + }), + { + wrapper: TestProvider, + } + ); + + await waitFor(() => { + expect(global.document.title).toEqual(expect.stringContaining('loki | elastic')); + }); + }); +}); diff --git a/public/app/features/explore/hooks/useExplorePageTitle.ts b/public/app/features/explore/hooks/useExplorePageTitle.ts index 491587315d5..c97f23a63b9 100644 --- a/public/app/features/explore/hooks/useExplorePageTitle.ts +++ b/public/app/features/explore/hooks/useExplorePageTitle.ts @@ -1,16 +1,53 @@ -import { isTruthy } from '@grafana/data'; +import { useEffect, useRef } from 'react'; + +import { NavModel } from '@grafana/data'; import { Branding } from 'app/core/components/Branding/Branding'; import { useNavModel } from 'app/core/hooks/useNavModel'; -import { useSelector } from 'app/types'; +import { safeParseJson } from 'app/core/utils/explore'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { ExploreQueryParams } from 'app/types'; -import { selectOrderedExplorePanes } from '../state/selectors'; +import { isFulfilled, hasKey } from './utils'; -export function useExplorePageTitle() { - const navModel = useNavModel('explore'); +export function useExplorePageTitle(params: ExploreQueryParams) { + const navModel = useRef(); + navModel.current = useNavModel('explore'); + const dsService = useRef(getDatasourceSrv()); - const datasourceNames = useSelector((state) => - Object.values(selectOrderedExplorePanes(state)).map((pane) => pane?.datasourceInstance?.name) - ).filter(isTruthy); + useEffect(() => { + if (!params.panes || typeof params.panes !== 'string') { + return; + } - document.title = `${navModel.main.text} - ${datasourceNames.join(' | ')} - ${Branding.AppTitle}`; + Promise.allSettled( + Object.values(safeParseJson(params.panes)).map((pane) => { + if ( + !pane || + typeof pane !== 'object' || + !hasKey('datasource', pane) || + !pane.datasource || + typeof pane.datasource !== 'string' + ) { + return Promise.reject(); + } + + return dsService.current.get(pane.datasource); + }) + ) + .then((results) => results.filter(isFulfilled).map((result) => result.value)) + .then((datasources) => { + if (!navModel.current) { + return; + } + + const names = datasources.map((ds) => ds.name); + + if (names.length === 0) { + global.document.title = `${navModel.current.main.text} - ${Branding.AppTitle}`; + return; + } + + global.document.title = `${navModel.current.main.text} - ${names.join(' | ')} - ${Branding.AppTitle}`; + }); + }, [params.panes]); } diff --git a/public/app/features/explore/hooks/useStateSync/index.test.tsx b/public/app/features/explore/hooks/useStateSync/index.test.tsx new file mode 100644 index 00000000000..b079b8048ee --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/index.test.tsx @@ -0,0 +1,443 @@ +import { act, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { createMemoryHistory } from 'history'; +import { stringify } from 'querystring'; +import React, { ReactNode } from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; + +import { UrlQueryMap } from '@grafana/data'; +import { HistoryWrapper, setDataSourceSrv } from '@grafana/runtime'; +import { setLastUsedDatasourceUID } from 'app/core/utils/explore'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; +import { configureStore } from 'app/store/configureStore'; + +import { makeDatasourceSetup } from '../../spec/helper/setup'; +import { splitClose, splitOpen } from '../../state/main'; + +import { useStateSync } from './'; + +interface SetupParams { + queryParams?: UrlQueryMap; + exploreMixedDatasource?: boolean; +} +function setup({ queryParams = {}, exploreMixedDatasource = false }: SetupParams) { + const history = createMemoryHistory({ + initialEntries: [{ pathname: '/explore', search: stringify(queryParams) }], + }); + + const location = new HistoryWrapper(history); + + const datasources = [ + makeDatasourceSetup({ name: 'loki', uid: 'loki-uid' }), + makeDatasourceSetup({ name: 'elastic', uid: 'elastic-uid' }), + ]; + + if (exploreMixedDatasource) { + datasources.push(makeDatasourceSetup({ name: MIXED_DATASOURCE_NAME, uid: MIXED_DATASOURCE_NAME, id: 999 })); + } + + setDataSourceSrv({ + get(datasource) { + let ds; + if (!datasource) { + ds = datasources[0]?.api; + } else { + ds = datasources.find((ds) => + typeof datasource === 'string' + ? ds.api.name === datasource || ds.api.uid === datasource + : ds.api.uid === datasource?.uid + )?.api; + } + + if (ds) { + return Promise.resolve(ds); + } + + return Promise.reject(); + }, + getInstanceSettings: jest.fn(), + getList: jest.fn(), + reload: jest.fn(), + }); + + const store = configureStore({ + user: { + orgId: 1, + fiscalYearStartMonth: 0, + isUpdating: false, + orgs: [], + orgsAreLoading: false, + sessions: [], + sessionsAreLoading: false, + teams: [], + teamsAreLoading: false, + timeZone: 'utc', + user: null, + weekStart: 'monday', + }, + }); + + const context = getGrafanaContextMock(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return { + ...renderHook<{ params: UrlQueryMap; children: ReactNode }, void>(({ params }) => useStateSync(params), { + wrapper, + initialProps: { + children: null, + params: queryParams, + }, + }), + location, + store, + }; +} + +describe('useStateSync', () => { + it('does not push a new entry to history on first render', async () => { + const { location, waitForNextUpdate } = setup({}); + + const initialHistoryLength = location.getHistory().length; + + await waitForNextUpdate(); + + expect(location.getHistory().length).toBe(initialHistoryLength); + + const search = location.getSearchObject(); + expect(search.panes).toBeDefined(); + }); + + it('inits an explore pane for each key in the panes search object', async () => { + const { location, waitForNextUpdate, store } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ datasource: { name: 'loki', uid: 'loki-uid' } }] }, + two: { datasource: 'elastic-uid', queries: [{ datasource: { name: 'elastic', uid: 'elastic-uid' } }] }, + }), + schemaVersion: 1, + }, + }); + + const initialHistoryLength = location.getHistory().length; + + await waitForNextUpdate(); + + expect(location.getHistory().length).toBe(initialHistoryLength); + + const search = location.getSearchObject(); + expect(search.panes).toBeDefined(); + expect(Object.keys(store.getState().explore.panes)).toHaveLength(2); + }); + + it('inits with a default query from the root level datasource when there are no valid queries in the URL', async () => { + const { location, waitForNextUpdate, store } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ datasource: { name: 'UNKNOWN', uid: 'UNKNOWN-DS' } }] }, + }), + schemaVersion: 1, + }, + }); + + const initialHistoryLength = location.getHistory().length; + + await waitForNextUpdate(); + + expect(location.getHistory().length).toBe(initialHistoryLength); + + const search = location.getSearchObject(); + expect(search.panes).toBeDefined(); + + const queries = store.getState().explore.panes['one']?.queries; + expect(queries).toHaveLength(1); + + expect(queries?.[0].datasource?.uid).toBe('loki-uid'); + }); + + it('inits with the last used datasource from localStorage', async () => { + setLastUsedDatasourceUID(1, 'elastic-uid'); + const { waitForNextUpdate, store } = setup({ + queryParams: {}, + }); + + await waitForNextUpdate(); + + expect(Object.values(store.getState().explore.panes)[0]?.datasourceInstance?.uid).toBe('elastic-uid'); + }); + + it('inits with the default datasource if the last used in localStorage does not exits', async () => { + setLastUsedDatasourceUID(1, 'unknown-ds-uid'); + const { waitForNextUpdate, store } = setup({ + queryParams: {}, + }); + + await waitForNextUpdate(); + + expect(Object.values(store.getState().explore.panes)[0]?.datasourceInstance?.uid).toBe('loki-uid'); + }); + + it('updates the state with correct queries from URL', async () => { + const { waitForNextUpdate, rerender, store } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + let queries = store.getState().explore.panes['one']?.queries; + expect(queries).toHaveLength(1); + expect(queries?.[0]).toMatchObject({ expr: 'a' }); + + rerender({ + children: null, + params: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }, { expr: 'b' }] }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + queries = store.getState().explore.panes['one']?.queries; + expect(queries).toHaveLength(2); + expect(queries?.[0]).toMatchObject({ expr: 'a' }); + expect(queries?.[1]).toMatchObject({ expr: 'b' }); + + rerender({ + children: null, + params: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + queries = store.getState().explore.panes['one']?.queries; + expect(queries).toHaveLength(1); + expect(queries?.[0]).toMatchObject({ expr: 'a' }); + }); + + it('Opens and closes the split pane if an a new pane is added or removed in the URL', async () => { + const { waitForNextUpdate, rerender, store } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + let panes = Object.keys(store.getState().explore.panes); + expect(panes).toHaveLength(1); + + rerender({ + children: null, + params: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }, { expr: 'b' }] }, + two: { datasource: 'loki-uid', queries: [{ expr: 'a' }, { expr: 'b' }] }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(Object.keys(store.getState().explore.panes)).toHaveLength(2); + + rerender({ + children: null, + params: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + await waitFor(() => { + expect(Object.keys(store.getState().explore.panes)).toHaveLength(1); + }); + }); + + it('Changes datasource when the datasource in the URL is updated', async () => { + const { waitForNextUpdate, rerender, store } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }] }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(store.getState().explore.panes['one']?.datasourceInstance?.getRef()).toMatchObject({ + type: 'logs', + uid: 'loki-uid', + }); + + rerender({ + children: null, + params: { + panes: JSON.stringify({ + one: { datasource: 'elastic-uid', queries: [{ expr: 'a' }, { expr: 'b' }] }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(store.getState().explore.panes['one']?.datasourceInstance?.getRef()).toMatchObject({ + type: 'logs', + uid: 'elastic-uid', + }); + }); + + it('Changes time rage when the range in the URL is updated', async () => { + const { waitForNextUpdate, rerender, store } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }], range: { from: 'now-1h', to: 'now' } }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(store.getState().explore.panes['one']?.range.raw).toMatchObject({ from: 'now-1h', to: 'now' }); + + rerender({ + children: null, + params: { + panes: JSON.stringify({ + one: { datasource: 'loki-uid', queries: [{ expr: 'a' }], range: { from: 'now-6h', to: 'now' } }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(store.getState().explore.panes['one']?.range.raw).toMatchObject({ from: 'now-6h', to: 'now' }); + }); + + it('uses the first query datasource if no root datasource is specified in the URL', async () => { + const { waitForNextUpdate, store } = setup({ + exploreMixedDatasource: true, + queryParams: { + panes: JSON.stringify({ + one: { + queries: [{ expr: 'b', datasource: { uid: 'loki-uid', type: 'logs' }, refId: 'B' }], + }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(store.getState().explore.panes['one']?.datasourceInstance?.getRef()).toMatchObject({ + uid: 'loki-uid', + type: 'logs', + }); + }); + + it('updates the URL opening and closing a pane datasource changes', async () => { + const { waitForNextUpdate, store, location } = setup({ + exploreMixedDatasource: true, + queryParams: { + panes: JSON.stringify({ + one: { + datasource: 'loki-uid', + queries: [{ expr: 'a', datasource: { uid: 'loki-uid', type: 'logs' }, refId: 'A' }], + }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(location.getHistory().length).toBe(1); + + expect(store.getState().explore.panes['one']?.datasourceInstance?.uid).toBe('loki-uid'); + + act(() => { + store.dispatch(splitOpen()); + }); + + await waitForNextUpdate(); + + await waitFor(async () => { + expect(location.getHistory().length).toBe(2); + }); + expect(Object.keys(store.getState().explore.panes)).toHaveLength(2); + + act(() => { + store.dispatch(splitClose('one')); + }); + + await waitFor(async () => { + expect(location.getHistory()).toHaveLength(3); + }); + }); + + describe('with exploreMixedDatasource enabled', () => { + it('filters out queries from the URL that do not have a datasource', async () => { + const { waitForNextUpdate, store } = setup({ + exploreMixedDatasource: true, + queryParams: { + panes: JSON.stringify({ + one: { + datasource: MIXED_DATASOURCE_NAME, + queries: [ + { expr: 'a', refId: 'A' }, + { expr: 'b', datasource: { uid: 'loki-uid', type: 'logs' }, refId: 'B' }, + ], + }, + }), + schemaVersion: 1, + }, + }); + + await waitForNextUpdate(); + + expect(store.getState().explore.panes['one']?.queries.length).toBe(1); + expect(store.getState().explore.panes['one']?.queries[0]).toMatchObject({ expr: 'b', refId: 'B' }); + }); + }); +}); diff --git a/public/app/features/explore/hooks/useStateSync.ts b/public/app/features/explore/hooks/useStateSync/index.ts similarity index 77% rename from public/app/features/explore/hooks/useStateSync.ts rename to public/app/features/explore/hooks/useStateSync/index.ts index 7b8a0c6237a..0e9bb998f09 100644 --- a/public/app/features/explore/hooks/useStateSync.ts +++ b/public/app/features/explore/hooks/useStateSync/index.ts @@ -1,30 +1,25 @@ import { identity, isEmpty, isEqual, isObject, mapValues, omitBy } from 'lodash'; import { useEffect, useRef } from 'react'; -import { - CoreApp, - serializeStateToUrlParam, - ExploreUrlState, - isDateTime, - TimeRange, - RawTimeRange, - DataSourceApi, -} from '@grafana/data'; +import { CoreApp, ExploreUrlState, isDateTime, TimeRange, RawTimeRange, DataSourceApi } from '@grafana/data'; import { DataQuery, DataSourceRef } from '@grafana/schema'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import { clearQueryKeys, getLastUsedDatasourceUID, parseUrlState } from 'app/core/utils/explore'; +import { clearQueryKeys, getLastUsedDatasourceUID } from 'app/core/utils/explore'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { addListener, ExploreId, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types'; +import { addListener, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types'; -import { changeDatasource } from '../state/datasource'; -import { initializeExplore } from '../state/explorePane'; -import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../state/main'; -import { runQueries, setQueriesAction } from '../state/query'; -import { selectPanes } from '../state/selectors'; -import { changeRangeAction, updateTime } from '../state/time'; -import { DEFAULT_RANGE } from '../state/utils'; -import { withUniqueRefIds } from '../utils/queries'; +import { changeDatasource } from '../../state/datasource'; +import { initializeExplore } from '../../state/explorePane'; +import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../../state/main'; +import { runQueries, setQueriesAction } from '../../state/query'; +import { selectPanes } from '../../state/selectors'; +import { changeRangeAction, updateTime } from '../../state/time'; +import { DEFAULT_RANGE } from '../../state/utils'; +import { withUniqueRefIds } from '../../utils/queries'; +import { isFulfilled } from '../utils'; + +import { parseURL } from './parseURL'; /** * Bi-directionally syncs URL changes with Explore's state. @@ -37,15 +32,15 @@ export function useStateSync(params: ExploreQueryParams) { }, } = useGrafana(); const dispatch = useDispatch(); - const statePanes = useSelector(selectPanes); + const panesState = useSelector(selectPanes); const orgId = useSelector((state) => state.user.orgId); - const prevParams = useRef(params); + const prevParams = useRef(params); const initState = useRef<'notstarted' | 'pending' | 'done'>('notstarted'); useEffect(() => { // This happens when the user navigates to an explore "empty page" while within Explore. // ie. by clicking on the explore when explore is active. - if (!params.left && !params.right) { + if (!params.panes) { initState.current = 'notstarted'; prevParams.current = params; } @@ -63,27 +58,32 @@ export function useStateSync(params: ExploreQueryParams) { action.type ), effect: async (_, { cancelActiveListeners, delay, getState }) => { - // The following 2 lines will throttle updates to avoid creating history entries when rapid changes + // The following 2 lines will throttle updates to avoid creating history entries when rapid changes // are committed to the store. cancelActiveListeners(); await delay(200); - const panesQueryParams = Object.entries(getState().explore.panes).reduce>( - (acc, [id, paneState]) => ({ ...acc, [id]: serializeStateToUrlParam(getUrlStateFromPaneState(paneState)) }), - {} - ); + const panesQueryParams = Object.entries(getState().explore.panes).reduce((acc, [id, paneState]) => { + if (!paneState) { + return acc; + } + return { + ...acc, + [id]: getUrlStateFromPaneState(paneState), + }; + }, {}); - if (!isEqual(prevParams.current, panesQueryParams)) { + if (!isEqual(prevParams.current.panes, JSON.stringify(panesQueryParams))) { // If there's no previous state it means we are mounting explore for the first time, // in this case we want to replace the URL instead of pushing a new entry to the history. - const replace = Object.values(prevParams.current).filter(Boolean).length === 0; + const replace = + !!prevParams.current.panes && Object.values(prevParams.current.panes).filter(Boolean).length === 0; - prevParams.current = panesQueryParams; + prevParams.current = { + panes: JSON.stringify(panesQueryParams), + }; - location.partial( - { left: panesQueryParams.left, right: panesQueryParams.right, orgId: getState().user.orgId }, - replace - ); + location.partial({ panes: prevParams.current.panes }, replace); } }, }) @@ -94,33 +94,29 @@ export function useStateSync(params: ExploreQueryParams) { }, [dispatch, location]); useEffect(() => { - const isURLOutOfSync = prevParams.current?.left !== params.left || prevParams.current?.right !== params.right; + const isURLOutOfSync = prevParams.current?.panes !== params.panes; - const urlPanes = { - left: parseUrlState(params.left), - ...(params.right && { right: parseUrlState(params.right) }), - }; + const urlState = parseURL(params); async function sync() { // if navigating the history causes one of the time range to not being equal to all the other ones, // we set syncedTimes to false to avoid inconsistent UI state. // Ideally `syncedTimes` should be saved in the URL. - if (Object.values(urlPanes).some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange))) { + if (Object.values(urlState.panes).some(({ range }, _, [{ range: firstRange }]) => !isEqual(range, firstRange))) { dispatch(syncTimesAction({ syncedTimes: false })); } - for (const [exploreId, urlPane] of Object.entries(urlPanes) as Array<[ExploreId, ExploreUrlState]>) { + Object.entries(urlState.panes).forEach(([exploreId, urlPane], i) => { const { datasource, queries, range, panelsState } = urlPane; - const statePane = statePanes[exploreId]; + const paneState = panesState[exploreId]; - if (statePane !== undefined) { - // TODO: the diff contains panelState updates, but we are currently not handling them. - const update = urlDiff(urlPane, getUrlStateFromPaneState(statePane)); + if (paneState !== undefined) { + const update = urlDiff(urlPane, getUrlStateFromPaneState(paneState)); Promise.resolve() .then(async () => { - if (update.datasource) { + if (update.datasource && datasource) { await dispatch(changeDatasource(exploreId, datasource)); } return; @@ -146,20 +142,21 @@ export function useStateSync(params: ExploreQueryParams) { dispatch( initializeExplore({ exploreId, - datasource, + datasource: datasource || '', queries: withUniqueRefIds(queries), range, panelsState, + position: i, }) ); } - } + }); // Close all the panes that are not in the URL but are still in the store // ie. because the user has navigated back after opening the split view. - Object.keys(statePanes) - .filter((keyInStore) => !Object.keys(urlPanes).includes(keyInStore)) - .forEach((paneId) => dispatch(splitClose(paneId as ExploreId))); + Object.keys(panesState) + .filter((keyInStore) => !Object.keys(urlState.panes).includes(keyInStore)) + .forEach((paneId) => dispatch(splitClose(paneId))); } // This happens when the user first navigates to explore. @@ -172,11 +169,11 @@ export function useStateSync(params: ExploreQueryParams) { dispatch(clearPanes()); Promise.all( - Object.entries(urlPanes).map(([exploreId, { datasource, queries, range, panelsState }]) => { + Object.entries(urlState.panes).map(([exploreId, { datasource, queries, range, panelsState }]) => { return getPaneDatasource(datasource, queries, orgId, !!exploreMixedDatasource).then( async (paneDatasource) => { return Promise.resolve( - // In theory, given the Grafana datasource will always be present, this should always be defined. + // FIXME: In theory, given the Grafana datasource will always be present, this should always be defined. paneDatasource ? queries.length ? // if we have queries in the URL, we use them @@ -212,7 +209,7 @@ export function useStateSync(params: ExploreQueryParams) { .then((queries) => { return dispatch( initializeExplore({ - exploreId: exploreId as ExploreId, + exploreId, datasource: paneDatasource, queries, range, @@ -224,25 +221,39 @@ export function useStateSync(params: ExploreQueryParams) { ); }) ).then((panes) => { - const urlState = panes.reduce((acc, { exploreId, state }) => { - return { ...acc, [exploreId]: serializeStateToUrlParam(getUrlStateFromPaneState(state)) }; - }, {}); - - location.partial({ ...urlState, orgId }, true); + const newParams = panes.reduce( + (acc, { exploreId, state }) => { + return { + ...acc, + panes: { + ...acc.panes, + [exploreId]: getUrlStateFromPaneState(state), + }, + }; + }, + { + panes: {}, + } + ); initState.current = 'done'; + + location.replace({ + search: Object.entries({ + panes: JSON.stringify(newParams.panes), + schemaVersion: urlState.schemaVersion, + orgId, + }) + .map(([key, value]) => `${key}=${value}`) + .join('&'), + }); }); } - prevParams.current = { - left: params.left, - }; - if (params.right) { - prevParams.current.right = params.right; - } + prevParams.current = params; isURLOutOfSync && initState.current === 'done' && sync(); - }, [params.left, params.right, dispatch, statePanes, exploreMixedDatasource, orgId, location]); + }, [dispatch, panesState, exploreMixedDatasource, orgId, location, params]); } function getDefaultQuery(ds: DataSourceApi) { @@ -342,9 +353,6 @@ async function getPaneDatasource( ); } -const isFulfilled = (promise: PromiseSettledResult): promise is PromiseFulfilledResult => - promise.status === 'fulfilled'; - /** * Compare 2 explore urls and return a map of what changed. Used to update the local state with all the * side effects needed. @@ -396,7 +404,7 @@ function pruneObject(obj: object): object | undefined { return pruned; } -export const toRawTimeRange = (range: TimeRange): RawTimeRange => { +const toRawTimeRange = (range: TimeRange): RawTimeRange => { let from = range.raw.from; if (isDateTime(from)) { from = from.valueOf().toString(10); diff --git a/public/app/features/explore/hooks/useStateSync/migrators/types.ts b/public/app/features/explore/hooks/useStateSync/migrators/types.ts new file mode 100644 index 00000000000..2983caca848 --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/migrators/types.ts @@ -0,0 +1,16 @@ +import { UrlQueryMap } from '@grafana/data'; + +export interface MigrationHandler { + /** + * The parse function is used to parse the URL parameters into the state object. + */ + parse: (params: UrlQueryMap) => To; + /** + * the migrate function takes a state object from the previous schema version and returns a new state object + */ + migrate?: From extends never ? never : (from: From) => To; +} + +export interface BaseExploreURL { + schemaVersion: number; +} diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v0.test.ts b/public/app/features/explore/hooks/useStateSync/migrators/v0.test.ts new file mode 100644 index 00000000000..94a746c2b6e --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/migrators/v0.test.ts @@ -0,0 +1,125 @@ +import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; + +import { v0Migrator } from './v0'; + +describe('v0 migrator', () => { + describe('parse', () => { + beforeEach(function () { + jest.spyOn(console, 'error').mockImplementation(() => void 0); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns default state on empty string', () => { + expect(v0Migrator.parse({})).toMatchObject({ + left: { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }, + }); + }); + + it('returns a valid Explore state from URL parameter', () => { + const paramValue = '{"datasource":"Local","queries":[{"expr":"metric"}],"range":{"from":"now-1h","to":"now"}}'; + expect(v0Migrator.parse({ left: paramValue })).toMatchObject({ + left: { + datasource: 'Local', + queries: [{ expr: 'metric' }], + range: { + from: 'now-1h', + to: 'now', + }, + }, + }); + }); + + it('returns a valid Explore state from right URL parameter', () => { + const paramValue = '{"datasource":"Local","queries":[{"expr":"metric"}],"range":{"from":"now-1h","to":"now"}}'; + expect(v0Migrator.parse({ right: paramValue })).toMatchObject({ + right: { + datasource: 'Local', + queries: [{ expr: 'metric' }], + range: { + from: 'now-1h', + to: 'now', + }, + }, + }); + }); + + it('returns a default state from invalid right URL parameter', () => { + const paramValue = 10; + expect(v0Migrator.parse({ right: paramValue })).toMatchObject({ + right: { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }, + }); + }); + + it('returns a valid Explore state from a compact URL parameter', () => { + const paramValue = + '["now-1h","now","Local",{"expr":"metric"},{"ui":[true,true,true,"none"],"__panelsState":{"logs":"1"}}]'; + expect(v0Migrator.parse({ left: paramValue })).toMatchObject({ + left: { + datasource: 'Local', + queries: [{ expr: 'metric' }], + range: { + from: 'now-1h', + to: 'now', + }, + panelsState: { + logs: '1', + }, + }, + }); + }); + + it('returns default state on compact URLs with too few segments ', () => { + const paramValue = '["now-1h",{"expr":"metric"},{"ui":[true,true,true,"none"]}]'; + expect(v0Migrator.parse({ left: paramValue })).toMatchObject({ + left: { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }, + }); + expect(console.error).toHaveBeenCalledWith('Error parsing compact URL state for Explore.'); + }); + + it('should not return a query for mode in the url', () => { + // Previous versions of Grafana included "Explore mode" in the URL; this should not be treated as a query. + const paramValue = + '["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"mode":"Metrics"},{"ui":[true,true,true,"none"]}]'; + expect(v0Migrator.parse({ left: paramValue })).toMatchObject({ + left: { + datasource: 'x-ray-datasource', + queries: [{ queryType: 'getTraceSummaries' }], + range: { + from: 'now-1h', + to: 'now', + }, + }, + }); + }); + + it('should return queries if queryType is present in the url', () => { + const paramValue = + '["now-1h","now","x-ray-datasource",{"queryType":"getTraceSummaries"},{"ui":[true,true,true,"none"]}]'; + expect(v0Migrator.parse({ left: paramValue })).toMatchObject({ + left: { + datasource: 'x-ray-datasource', + queries: [{ queryType: 'getTraceSummaries' }], + range: { + from: 'now-1h', + to: 'now', + }, + }, + }); + }); + }); +}); diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v0.ts b/public/app/features/explore/hooks/useStateSync/migrators/v0.ts new file mode 100644 index 00000000000..c1d641facad --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/migrators/v0.ts @@ -0,0 +1,66 @@ +import { ExploreUrlState } from '@grafana/data'; +import { safeParseJson } from 'app/core/utils/explore'; +import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; + +import { BaseExploreURL, MigrationHandler } from './types'; + +export interface ExploreURLV0 extends BaseExploreURL { + schemaVersion: 0; + left: ExploreUrlState; + right?: ExploreUrlState; +} + +export const v0Migrator: MigrationHandler = { + parse: (params) => { + return { + schemaVersion: 0, + left: parseUrlState(typeof params.left === 'string' ? params.left : undefined), + ...(params.right && { + right: parseUrlState(typeof params.right === 'string' ? params.right : undefined), + }), + }; + }, +}; + +const isSegment = (segment: { [key: string]: string }, ...props: string[]) => + props.some((prop) => segment.hasOwnProperty(prop)); + +enum ParseUrlStateIndex { + RangeFrom = 0, + RangeTo = 1, + Datasource = 2, + SegmentsStart = 3, +} + +function parseUrlState(initial: string | undefined): ExploreUrlState { + const parsed = safeParseJson(initial); + const errorResult = { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }; + + if (!parsed) { + return errorResult; + } + + if (!Array.isArray(parsed)) { + return { queries: [], range: DEFAULT_RANGE, ...parsed }; + } + + if (parsed.length <= ParseUrlStateIndex.SegmentsStart) { + console.error('Error parsing compact URL state for Explore.'); + return errorResult; + } + + const range = { + from: parsed[ParseUrlStateIndex.RangeFrom], + to: parsed[ParseUrlStateIndex.RangeTo], + }; + const datasource = parsed[ParseUrlStateIndex.Datasource]; + const parsedSegments = parsed.slice(ParseUrlStateIndex.SegmentsStart); + const queries = parsedSegments.filter((segment) => !isSegment(segment, 'ui', 'mode', '__panelsState')); + + const panelsState = parsedSegments.find((segment) => isSegment(segment, '__panelsState'))?.__panelsState; + return { datasource, queries, range, panelsState }; +} diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v1.test.ts b/public/app/features/explore/hooks/useStateSync/migrators/v1.test.ts new file mode 100644 index 00000000000..922efb9e99e --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/migrators/v1.test.ts @@ -0,0 +1,118 @@ +import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; + +import { v1Migrator } from './v1'; + +jest.mock('app/core/utils/explore', () => ({ + ...jest.requireActual('app/core/utils/explore'), + generateExploreId: () => 'ID', +})); + +describe('v1 migrator', () => { + describe('parse', () => { + beforeEach(function () { + jest.spyOn(console, 'error').mockImplementation(() => void 0); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('correctly returns default state when no params are provided', () => { + expect(v1Migrator.parse({})).toMatchObject({ + panes: { + ID: { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }, + }, + }); + }); + + it('correctly returns default state when panes param is an empty object', () => { + expect(v1Migrator.parse({ panes: '{}' })).toMatchObject({ + panes: { + ID: { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }, + }, + }); + }); + + it('correctly returns default state when panes param is not a valid JSON object', () => { + expect(v1Migrator.parse({ panes: '{a malformed json}' })).toMatchObject({ + panes: { + ID: { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }, + }, + }); + + expect(console.error).toHaveBeenCalledTimes(1); + }); + + it('correctly returns default state when a pane in panes params is an empty object', () => { + expect(v1Migrator.parse({ panes: '{"aaa": {}}' })).toMatchObject({ + panes: { + aaa: { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }, + }, + }); + }); + + it('correctly returns default state when a pane in panes params is not a valid JSON object', () => { + expect(v1Migrator.parse({ panes: '{"aaa": "NOT A VALID URL STATE"}' })).toMatchObject({ + panes: { + aaa: { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }, + }, + }); + }); + + it('correctly parses state', () => { + expect( + v1Migrator.parse({ + panes: `{ + "aaa": { + "datasource": "my-ds", + "queries": [ + { + "refId": "A" + } + ], + "range": { + "from": "now", + "to": "now-5m" + } + } + }`, + }) + ).toMatchObject({ + panes: { + aaa: { + datasource: 'my-ds', + queries: [{ refId: 'A' }], + range: { + from: 'now', + to: 'now-5m', + }, + }, + }, + }); + }); + }); + + describe('migrate', () => { + // TODO: implement + }); +}); diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v1.ts b/public/app/features/explore/hooks/useStateSync/migrators/v1.ts new file mode 100644 index 00000000000..5dc6a9a6ca0 --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/migrators/v1.ts @@ -0,0 +1,89 @@ +import { ExploreUrlState } from '@grafana/data'; +import { generateExploreId, safeParseJson } from 'app/core/utils/explore'; +import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; + +import { hasKey } from '../../utils'; + +import { BaseExploreURL, MigrationHandler } from './types'; +import { ExploreURLV0 } from './v0'; + +export interface ExploreURLV1 extends BaseExploreURL { + schemaVersion: 1; + panes: { + [id: string]: ExploreUrlState; + }; +} + +export const v1Migrator: MigrationHandler = { + parse: (params) => { + if (!params || !params.panes || typeof params.panes !== 'string') { + return { + schemaVersion: 1, + panes: { + [generateExploreId()]: DEFAULT_STATE, + }, + }; + } + + const rawPanes: Record = safeParseJson(params.panes) || {}; + + const panes = Object.entries(rawPanes) + .map(([key, value]) => [key, applyDefaults(value)] as const) + .reduce>((acc, [key, value]) => { + return { + ...acc, + [key]: value, + }; + }, {}); + + if (!Object.keys(panes).length) { + panes[generateExploreId()] = DEFAULT_STATE; + } + + return { + schemaVersion: 1, + panes, + }; + }, + migrate: (params) => { + return { + schemaVersion: 1, + panes: { + [generateExploreId()]: params.left, + ...(params.right && { [generateExploreId()]: params.right }), + }, + }; + }, +}; + +const DEFAULT_STATE: ExploreUrlState = { + datasource: null, + queries: [], + range: DEFAULT_RANGE, +}; + +function applyDefaults(input: unknown): ExploreUrlState { + if (!input || typeof input !== 'object') { + return DEFAULT_STATE; + } + + return { + ...DEFAULT_STATE, + // queries + ...(hasKey('queries', input) && Array.isArray(input.queries) && { queries: input.queries }), + // datasource + ...(hasKey('datasource', input) && typeof input.datasource === 'string' && { datasource: input.datasource }), + // panelsState + ...(hasKey('panelsState', input) && + !!input.panelsState && + typeof input.panelsState === 'object' && { panelsState: input.panelsState }), + // range + ...(hasKey('range', input) && + !!input.range && + typeof input.range === 'object' && + hasKey('from', input.range) && + hasKey('to', input.range) && + typeof input.range.from === 'string' && + typeof input.range.to === 'string' && { range: { from: input.range.from, to: input.range.to } }), + }; +} diff --git a/public/app/features/explore/hooks/useStateSync/parseURL.ts b/public/app/features/explore/hooks/useStateSync/parseURL.ts new file mode 100644 index 00000000000..0a67b625880 --- /dev/null +++ b/public/app/features/explore/hooks/useStateSync/parseURL.ts @@ -0,0 +1,44 @@ +import { ExploreQueryParams } from 'app/types'; + +import { v0Migrator } from './migrators/v0'; +import { ExploreURLV1, v1Migrator } from './migrators/v1'; + +type ExploreURL = ExploreURLV1; + +export const parseURL = (params: ExploreQueryParams) => { + return migrate(params); +}; + +const migrators = [v0Migrator, v1Migrator] as const; + +const migrate = (params: ExploreQueryParams): ExploreURL => { + const schemaVersion = getSchemaVersion(params); + + const [parser, ...migratorsToRun] = migrators.slice(schemaVersion); + + const parsedUrl = parser.parse(params); + + // @ts-expect-error + const final: ExploreURL = migratorsToRun.reduce((acc, migrator) => { + // @ts-expect-error + return migrator.migrate ? migrator.migrate(acc) : acc; + }, parsedUrl); + + return final; +}; + +function getSchemaVersion(params: ExploreQueryParams): number { + if (!params || !('schemaVersion' in params) || !params.schemaVersion) { + return 0; + } + + if (typeof params.schemaVersion === 'number') { + return params.schemaVersion; + } + + if (typeof params.schemaVersion === 'string') { + return Number.parseInt(params.schemaVersion, 10); + } + + return 0; +} diff --git a/public/app/features/explore/hooks/useStopQueries.ts b/public/app/features/explore/hooks/useStopQueries.ts deleted file mode 100644 index c233aeb9f86..00000000000 --- a/public/app/features/explore/hooks/useStopQueries.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useRef } from 'react'; - -import { stopQueryState } from 'app/core/utils/explore'; -import { useSelector } from 'app/types'; - -import { selectPanes } from '../state/selectors'; - -/** - * Unsubscribe from queries when unmounting. - * This avoids unnecessary state changes when navigating away from Explore. - */ -export function useStopQueries() { - const panesRef = useRef>({}); - panesRef.current = useSelector(selectPanes); - - useEffect(() => { - return () => { - for (const [, pane] of Object.entries(panesRef.current)) { - stopQueryState(pane.querySubscription); - } - }; - }, []); -} diff --git a/public/app/features/explore/hooks/useTimeSrvFix.test.tsx b/public/app/features/explore/hooks/useTimeSrvFix.test.tsx new file mode 100644 index 00000000000..332613a7938 --- /dev/null +++ b/public/app/features/explore/hooks/useTimeSrvFix.test.tsx @@ -0,0 +1,45 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { stringify } from 'querystring'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; + +import { HistoryWrapper } from '@grafana/runtime'; + +import { useTimeSrvFix } from './useTimeSrvFix'; + +describe('useTimeSrvFix', () => { + it('removes `from` and `to` parameters from url when first mounted', async () => { + const history = createMemoryHistory({ + initialEntries: [{ pathname: '/explore', search: stringify({ from: '1', to: '2' }) }], + }); + + const location = new HistoryWrapper(history); + + const context = getGrafanaContextMock(); + + renderHook(() => useTimeSrvFix(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(location.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' })); + }); + }); +}); diff --git a/public/app/features/explore/hooks/useTimeSrvFix.ts b/public/app/features/explore/hooks/useTimeSrvFix.ts index c3af20e78b8..dac29f21714 100644 --- a/public/app/features/explore/hooks/useTimeSrvFix.ts +++ b/public/app/features/explore/hooks/useTimeSrvFix.ts @@ -15,6 +15,7 @@ import { useGrafana } from 'app/core/context/GrafanaContext'; */ export function useTimeSrvFix() { const { location } = useGrafana(); + useEffect(() => { const searchParams = location.getSearchObject(); if (searchParams.from || searchParams.to) { diff --git a/public/app/features/explore/hooks/utils.ts b/public/app/features/explore/hooks/utils.ts new file mode 100644 index 00000000000..ff554495dd4 --- /dev/null +++ b/public/app/features/explore/hooks/utils.ts @@ -0,0 +1,7 @@ +export const isFulfilled = (promise: PromiseSettledResult): promise is PromiseFulfilledResult => + promise.status === 'fulfilled'; + +// TS<5 does not support `in` operator for type narrowing. once we upgrade to TS5, we can remove this function and just use the in operator instead. +export function hasKey(k: K, o: T): o is T & Record { + return k in o; +} diff --git a/public/app/features/explore/spec/datasourceState.test.tsx b/public/app/features/explore/spec/datasourceState.test.tsx index 9850e225dd8..55778487cee 100644 --- a/public/app/features/explore/spec/datasourceState.test.tsx +++ b/public/app/features/explore/spec/datasourceState.test.tsx @@ -1,7 +1,5 @@ import { screen, waitFor } from '@testing-library/react'; -import { serializeStateToUrlParam } from '@grafana/data'; - import { changeDatasource } from './helper/interactions'; import { makeLogsQueryResponse } from './helper/query'; import { setupExplore, tearDown, waitForExplore } from './helper/setup'; @@ -15,25 +13,14 @@ describe('Explore: handle datasource states', () => { await waitFor(() => screen.getByText(/Explore requires at least one data source/i)); }); - it('handles changing the datasource manually', async () => { + it('handles datasource changes', async () => { const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) }; - const { datasources, location } = setupExplore({ urlParams }); + const { datasources } = setupExplore({ urlParams }); jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse()); await waitForExplore(); await changeDatasource('elastic'); await screen.findByText('elastic Editor input:'); expect(datasources.elastic.query).not.toBeCalled(); - - await waitFor(async () => { - expect(location.getSearchObject()).toEqual({ - orgId: '1', - left: serializeStateToUrlParam({ - datasource: 'elastic-uid', - queries: [{ refId: 'A', datasource: { type: 'logs', uid: 'elastic-uid' } }], - range: { from: 'now-1h', to: 'now' }, - }), - }); - }); }); }); diff --git a/public/app/features/explore/spec/helper/assert.ts b/public/app/features/explore/spec/helper/assert.ts index fdf337b8fe6..8f072a00e71 100644 --- a/public/app/features/explore/spec/helper/assert.ts +++ b/public/app/features/explore/spec/helper/assert.ts @@ -1,10 +1,8 @@ import { waitFor } from '@testing-library/react'; -import { ExploreId } from '../../../../types'; - import { withinExplore } from './setup'; -export const assertQueryHistoryExists = async (query: string, exploreId: ExploreId = ExploreId.left) => { +export const assertQueryHistoryExists = async (query: string, exploreId = 'left') => { const selector = withinExplore(exploreId); expect(await selector.findByText('1 queries')).toBeInTheDocument(); @@ -12,7 +10,7 @@ export const assertQueryHistoryExists = async (query: string, exploreId: Explore expect(queryItem).toHaveTextContent(query); }; -export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId: ExploreId = ExploreId.left) => { +export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId = 'left') => { const selector = withinExplore(exploreId); await waitFor(() => { expect(selector.getByText(new RegExp(`${expectedQueryTexts.length} queries`))).toBeInTheDocument(); @@ -23,10 +21,7 @@ export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId }); }; -export const assertQueryHistoryComment = async ( - expectedQueryComments: string[], - exploreId: ExploreId = ExploreId.left -) => { +export const assertQueryHistoryComment = async (expectedQueryComments: string[], exploreId = 'left') => { const selector = withinExplore(exploreId); await waitFor(() => { expect(selector.getByText(new RegExp(`${expectedQueryComments.length} queries`))).toBeInTheDocument(); @@ -37,7 +32,7 @@ export const assertQueryHistoryComment = async ( }); }; -export const assertQueryHistoryIsStarred = async (expectedStars: boolean[], exploreId: ExploreId = ExploreId.left) => { +export const assertQueryHistoryIsStarred = async (expectedStars: boolean[], exploreId = 'left') => { const selector = withinExplore(exploreId); const starButtons = selector.getAllByRole('button', { name: /Star query|Unstar query/ }); await waitFor(() => @@ -49,12 +44,12 @@ export const assertQueryHistoryIsStarred = async (expectedStars: boolean[], expl export const assertQueryHistoryTabIsSelected = ( tabName: 'Query history' | 'Starred' | 'Settings', - exploreId: ExploreId = ExploreId.left + exploreId = 'left' ) => { expect(withinExplore(exploreId).getByRole('tab', { name: `Tab ${tabName}`, selected: true })).toBeInTheDocument(); }; -export const assertDataSourceFilterVisibility = (visible: boolean, exploreId: ExploreId = ExploreId.left) => { +export const assertDataSourceFilterVisibility = (visible: boolean, exploreId = 'left') => { const filterInput = withinExplore(exploreId).queryByLabelText('Filter queries for data sources(s)'); if (visible) { expect(filterInput).toBeInTheDocument(); @@ -63,14 +58,10 @@ export const assertDataSourceFilterVisibility = (visible: boolean, exploreId: Ex } }; -export const assertQueryHistoryElementsShown = ( - shown: number, - total: number, - exploreId: ExploreId = ExploreId.left -) => { +export const assertQueryHistoryElementsShown = (shown: number, total: number, exploreId = 'left') => { expect(withinExplore(exploreId).queryByText(`Showing ${shown} of ${total}`)).toBeInTheDocument(); }; -export const assertLoadMoreQueryHistoryNotVisible = (exploreId: ExploreId = ExploreId.left) => { +export const assertLoadMoreQueryHistoryNotVisible = (exploreId = 'left') => { expect(withinExplore(exploreId).queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); }; diff --git a/public/app/features/explore/spec/helper/interactions.ts b/public/app/features/explore/spec/helper/interactions.ts index a981dab22c4..82ed540b1b7 100644 --- a/public/app/features/explore/spec/helper/interactions.ts +++ b/public/app/features/explore/spec/helper/interactions.ts @@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event'; import { selectors } from '@grafana/e2e-selectors'; -import { ExploreId } from '../../../../types'; - import { withinExplore } from './setup'; export const changeDatasource = async (name: string) => { @@ -14,59 +12,52 @@ export const changeDatasource = async (name: string) => { fireEvent.click(option); }; -export const inputQuery = async (query: string, exploreId: ExploreId = ExploreId.left) => { +export const inputQuery = async (query: string, exploreId = 'left') => { const input = withinExplore(exploreId).getByRole('textbox', { name: 'query' }); await userEvent.clear(input); await userEvent.type(input, query); }; -export const runQuery = async (exploreId: ExploreId = ExploreId.left) => { +export const runQuery = async (exploreId = 'left') => { const explore = withinExplore(exploreId); const toolbar = within(explore.getByLabelText('Explore toolbar')); const button = toolbar.getByRole('button', { name: /run query/i }); await userEvent.click(button); }; -export const openQueryHistory = async (exploreId: ExploreId = ExploreId.left) => { +export const openQueryHistory = async (exploreId = 'left') => { const selector = withinExplore(exploreId); const button = selector.getByRole('button', { name: 'Rich history button' }); await userEvent.click(button); expect(await selector.findByPlaceholderText('Search queries')).toBeInTheDocument(); }; -export const closeQueryHistory = async (exploreId: ExploreId = ExploreId.left) => { +export const closeQueryHistory = async (exploreId = 'left') => { const closeButton = withinExplore(exploreId).getByRole('button', { name: 'Close query history' }); await userEvent.click(closeButton); }; -export const switchToQueryHistoryTab = async ( - name: 'Settings' | 'Query History', - exploreId: ExploreId = ExploreId.left -) => { +export const switchToQueryHistoryTab = async (name: 'Settings' | 'Query History', exploreId = 'left') => { await userEvent.click(withinExplore(exploreId).getByRole('tab', { name: `Tab ${name}` })); }; -export const selectStarredTabFirst = async (exploreId: ExploreId = ExploreId.left) => { +export const selectStarredTabFirst = async (exploreId = 'left') => { const checkbox = withinExplore(exploreId).getByRole('checkbox', { name: /Change the default active tab from “Query history” to “Starred”/, }); await userEvent.click(checkbox); }; -export const selectOnlyActiveDataSource = async (exploreId: ExploreId = ExploreId.left) => { +export const selectOnlyActiveDataSource = async (exploreId = 'left') => { const checkbox = withinExplore(exploreId).getByLabelText(/Only show queries for data source currently active.*/); await userEvent.click(checkbox); }; -export const starQueryHistory = async (queryIndex: number, exploreId: ExploreId = ExploreId.left) => { +export const starQueryHistory = async (queryIndex: number, exploreId = 'left') => { await invokeAction(queryIndex, 'Star query', exploreId); }; -export const commentQueryHistory = async ( - queryIndex: number, - comment: string, - exploreId: ExploreId = ExploreId.left -) => { +export const commentQueryHistory = async (queryIndex: number, comment: string, exploreId = 'left') => { await invokeAction(queryIndex, 'Add comment', exploreId); const input = withinExplore(exploreId).getByPlaceholderText('An optional description of what the query does.'); await userEvent.clear(input); @@ -74,16 +65,16 @@ export const commentQueryHistory = async ( await invokeAction(queryIndex, 'Save comment', exploreId); }; -export const deleteQueryHistory = async (queryIndex: number, exploreId: ExploreId = ExploreId.left) => { +export const deleteQueryHistory = async (queryIndex: number, exploreId = 'left') => { await invokeAction(queryIndex, 'Delete query', exploreId); }; -export const loadMoreQueryHistory = async (exploreId: ExploreId = ExploreId.left) => { +export const loadMoreQueryHistory = async (exploreId = 'left') => { const button = withinExplore(exploreId).getByRole('button', { name: 'Load more' }); await userEvent.click(button); }; -const invokeAction = async (queryIndex: number, actionAccessibleName: string, exploreId: ExploreId) => { +const invokeAction = async (queryIndex: number, actionAccessibleName: string, exploreId: string) => { const selector = withinExplore(exploreId); const buttons = selector.getAllByRole('button', { name: actionAccessibleName }); await userEvent.click(buttons[queryIndex]); diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx index c640e31f18f..4397d0c28c4 100644 --- a/public/app/features/explore/spec/helper/setup.tsx +++ b/public/app/features/explore/spec/helper/setup.tsx @@ -33,7 +33,7 @@ import { configureStore } from 'app/store/configureStore'; import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource'; import { LokiQuery } from '../../../../plugins/datasource/loki/types'; -import { ExploreId, ExploreQueryParams } from '../../../../types'; +import { ExploreQueryParams } from '../../../../types'; import { initialUserState } from '../../../profile/state/reducers'; import ExplorePage from '../../ExplorePage'; @@ -42,7 +42,7 @@ type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceAp type SetupOptions = { clearLocalStorage?: boolean; datasources?: DatasourceSetup[]; - urlParams?: ExploreQueryParams & { [key: string]: string }; + urlParams?: ExploreQueryParams; prevUsedDatasource?: { orgId: number; datasource: string }; mixedEnabled?: boolean; }; @@ -166,7 +166,7 @@ export function setupExplore(options?: SetupOptions): { }; } -function makeDatasourceSetup({ +export function makeDatasourceSetup({ name = 'loki', id = 1, uid: uidOverride, @@ -232,10 +232,10 @@ function makeDatasourceSetup({ }; } -export const waitForExplore = (exploreId: ExploreId = ExploreId.left) => { +export const waitForExplore = (exploreId = 'left') => { return waitFor(async () => { const container = screen.getAllByTestId('data-testid Explore'); - return within(container[exploreId === ExploreId.left ? 0 : 1]); + return within(container[exploreId === 'left' ? 0 : 1]); }); }; @@ -243,7 +243,7 @@ export const tearDown = () => { window.localStorage.clear(); }; -export const withinExplore = (exploreId: ExploreId) => { +export const withinExplore = (exploreId: string) => { const container = screen.getAllByTestId('data-testid Explore'); - return within(container[exploreId === ExploreId.left ? 0 : 1]); + return within(container[exploreId === 'left' ? 0 : 1]); }; diff --git a/public/app/features/explore/spec/query.test.tsx b/public/app/features/explore/spec/query.test.tsx index cfc8e69e286..5703508320b 100644 --- a/public/app/features/explore/spec/query.test.tsx +++ b/public/app/features/explore/spec/query.test.tsx @@ -1,32 +1,22 @@ -import { act, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { serializeStateToUrlParam } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { makeLogsQueryResponse, makeMetricsQueryResponse } from './helper/query'; +import { makeLogsQueryResponse } from './helper/query'; import { setupExplore, tearDown, waitForExplore } from './helper/setup'; describe('Explore: handle running/not running query', () => { afterEach(() => { tearDown(); }); - it('inits url and renders editor but does not call query on empty url', async () => { + it('inits and renders editor but does not call query on empty initial state', async () => { const { datasources } = setupExplore(); await waitForExplore(); - // At this point url should be initialised to some defaults - expect(locationService.getSearchObject()).toEqual({ - orgId: '1', - left: serializeStateToUrlParam({ - datasource: 'loki-uid', - queries: [{ refId: 'A', datasource: { type: 'logs', uid: 'loki-uid' } }], - range: { from: 'now-1h', to: 'now' }, - }), - }); expect(datasources.loki.query).not.toBeCalled(); }); - it('runs query when url contains query and renders results', async () => { + it('runs query when initial state contains query and renders results', async () => { const urlParams = { left: serializeStateToUrlParam({ datasource: 'loki-uid', @@ -46,61 +36,10 @@ describe('Explore: handle running/not running query', () => { // And that the editor gets the expr from the url await screen.findByText(`loki Editor input: { label="value"}`); - // We did not change the url - expect(locationService.getSearchObject()).toEqual({ - orgId: '1', - ...urlParams, - }); - // We called the data source query method once expect(datasources.loki.query).toBeCalledTimes(1); expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({ targets: [{ expr: '{ label="value"}' }], }); }); - - describe('handles url change', () => { - const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; - - it('and runs the new query', async () => { - const { datasources } = setupExplore({ urlParams }); - jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse()); - // Wait for rendering the logs - await screen.findByText(/custom log line/i); - - jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse('different log')); - - act(() => { - locationService.partial({ - left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="different"}' }]), - }); - }); - - // Editor renders the new query - await screen.findByText(`loki Editor input: { label="different"}`); - // Renders new response - await screen.findByText(/different log/i); - }); - - it('and runs the new query with different datasource', async () => { - const { datasources } = setupExplore({ urlParams }); - jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse()); - // Wait for rendering the logs - await screen.findByText(/custom log line/i); - await screen.findByText(`loki Editor input: { label="value"}`); - - jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeMetricsQueryResponse()); - - act(() => { - locationService.partial({ - left: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'other query' }]), - }); - }); - - // Editor renders the new query - await screen.findByText(`elastic Editor input: other query`); - // Renders graph - await screen.findByText(/Graph/i); - }); - }); }); diff --git a/public/app/features/explore/spec/queryHistory.test.tsx b/public/app/features/explore/spec/queryHistory.test.tsx index 15613188e3f..0afbe089734 100644 --- a/public/app/features/explore/spec/queryHistory.test.tsx +++ b/public/app/features/explore/spec/queryHistory.test.tsx @@ -3,7 +3,6 @@ import { of } from 'rxjs'; import { serializeStateToUrlParam } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { ExploreId } from 'app/types'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; @@ -155,26 +154,26 @@ describe('Explore: Query History', () => { const { datasources } = setupExplore({ urlParams }); jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse()); await waitForExplore(); - await waitForExplore(ExploreId.right); + await waitForExplore('right'); // queries in history - await openQueryHistory(ExploreId.left); - await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], ExploreId.left); - await openQueryHistory(ExploreId.right); - await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], ExploreId.right); + await openQueryHistory('left'); + await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], 'left'); + await openQueryHistory('right'); + await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], 'right'); // star one one query - await starQueryHistory(1, ExploreId.left); - await assertQueryHistoryIsStarred([false, true], ExploreId.left); - await assertQueryHistoryIsStarred([false, true], ExploreId.right); + await starQueryHistory(1, 'left'); + await assertQueryHistoryIsStarred([false, true], 'left'); + await assertQueryHistoryIsStarred([false, true], 'right'); expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_starred', { queryHistoryEnabled: false, newValue: true, }); - await deleteQueryHistory(0, ExploreId.left); - await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left); - await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.right); + await deleteQueryHistory(0, 'left'); + await assertQueryHistory(['{"expr":"query #1"}'], 'left'); + await assertQueryHistory(['{"expr":"query #1"}'], 'right'); expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_deleted', { queryHistoryEnabled: false, }); @@ -193,10 +192,10 @@ describe('Explore: Query History', () => { jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse()); await waitForExplore(); await openQueryHistory(); - await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left); + await assertQueryHistory(['{"expr":"query #1"}'], 'left'); await commentQueryHistory(0, 'test comment'); - await assertQueryHistoryComment(['test comment'], ExploreId.left); + await assertQueryHistoryComment(['test comment'], 'left'); }); it('updates query history settings', async () => { diff --git a/public/app/features/explore/state/datasource.test.ts b/public/app/features/explore/state/datasource.test.ts index 1218c074d67..b024fdb35dd 100644 --- a/public/app/features/explore/state/datasource.test.ts +++ b/public/app/features/explore/state/datasource.test.ts @@ -1,6 +1,6 @@ import { DataSourceApi } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; -import { ExploreId, ExploreItemState } from 'app/types'; +import { ExploreItemState } from 'app/types'; import { updateDatasourceInstanceAction, datasourceReducer } from './datasource'; import { createEmptyQueryResponse } from './utils'; @@ -27,7 +27,7 @@ describe('Datasource reducer', () => { const result = datasourceReducer( initialState, - updateDatasourceInstanceAction({ exploreId: ExploreId.left, datasourceInstance, history: [] }) + updateDatasourceInstanceAction({ exploreId: 'left', datasourceInstance, history: [] }) ); const expectedState: Partial = { diff --git a/public/app/features/explore/state/datasource.ts b/public/app/features/explore/state/datasource.ts index 18e183ae05b..61ed5b1aa13 100644 --- a/public/app/features/explore/state/datasource.ts +++ b/public/app/features/explore/state/datasource.ts @@ -7,7 +7,6 @@ import { DataSourceRef } from '@grafana/schema'; import { RefreshPicker } from '@grafana/ui'; import { stopQueryState } from 'app/core/utils/explore'; import { ExploreItemState, ThunkResult } from 'app/types'; -import { ExploreId } from 'app/types/explore'; import { loadSupplementaryQueries } from '../utils/supplementaryQueries'; @@ -23,7 +22,7 @@ import { createEmptyQueryResponse, loadAndInitDatasource } from './utils'; * Updates datasource instance before datasource loading has started */ export interface UpdateDatasourceInstancePayload { - exploreId: ExploreId; + exploreId: string; datasourceInstance: DataSourceApi; history: HistoryItem[]; } @@ -39,7 +38,7 @@ export const updateDatasourceInstanceAction = createAction> { diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index 35c3633c770..f79bae831f2 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -13,7 +13,7 @@ import { DataQuery, DataSourceRef } from '@grafana/schema'; import { getQueryKeys } from 'app/core/utils/explore'; import { getTimeZone } from 'app/features/profile/state/selectors'; import { createAsyncThunk, ThunkResult } from 'app/types'; -import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { ExploreItemState } from 'app/types/explore'; import { datasourceReducer } from './datasource'; import { historyReducer } from './history'; @@ -32,7 +32,7 @@ import { makeExplorePaneState, loadAndInitDatasource, createEmptyQueryResponse, * The width will be used to calculate graph intervals (number of datapoints). */ export interface ChangeSizePayload { - exploreId: ExploreId; + exploreId: string; width: number; } export const changeSizeAction = createAction('explore/changeSize'); @@ -41,12 +41,12 @@ export const changeSizeAction = createAction('explore/changeS * Tracks the state of explore panels that gets synced with the url. */ interface ChangePanelsState { - exploreId: ExploreId; + exploreId: string; panelsState: ExplorePanelsState; } const changePanelsStateAction = createAction('explore/changePanels'); export function changePanelState( - exploreId: ExploreId, + exploreId: string, panel: PreferredVisualisationType, panelState: ExplorePanelsState[PreferredVisualisationType] ): ThunkResult { @@ -73,7 +73,7 @@ export function changePanelState( * Call this only on components for with the Explore state has not been initialized. */ interface InitializeExplorePayload { - exploreId: ExploreId; + exploreId: string; queries: DataQuery[]; range: TimeRange; history: HistoryItem[]; @@ -82,7 +82,7 @@ interface InitializeExplorePayload { const initializeExploreAction = createAction('explore/initializeExploreAction'); export interface SetUrlReplacedPayload { - exploreId: ExploreId; + exploreId: string; } export const setUrlReplacedAction = createAction('explore/setUrlReplaced'); @@ -90,16 +90,17 @@ export const setUrlReplacedAction = createAction('explore * Keep track of the Explore container size, in particular the width. * The width will be used to calculate graph intervals (number of datapoints). */ -export function changeSize(exploreId: ExploreId, { width }: { width: number }): PayloadAction { +export function changeSize(exploreId: string, { width }: { width: number }): PayloadAction { return changeSizeAction({ exploreId, width }); } interface InitializeExploreOptions { - exploreId: ExploreId; + exploreId: string; datasource: DataSourceRef | string | undefined; queries: DataQuery[]; range: RawTimeRange; panelsState?: ExplorePanelsState; + position?: number; } /** * Initialize Explore state with state from the URL and the React component. diff --git a/public/app/features/explore/state/history.ts b/public/app/features/explore/state/history.ts index 8c46f0dce72..8a02945ceb1 100644 --- a/public/app/features/explore/state/history.ts +++ b/public/app/features/explore/state/history.ts @@ -12,7 +12,7 @@ import { updateRichHistorySettings, updateStarredInRichHistory, } from 'app/core/utils/richHistory'; -import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types'; +import { ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types'; import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider'; import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes'; @@ -24,13 +24,14 @@ import { richHistoryStorageFullAction, richHistoryUpdatedAction, } from './main'; +import { selectPanesEntries } from './selectors'; // // Actions and Payloads // export interface HistoryUpdatedPayload { - exploreId: ExploreId; + exploreId: string; history: HistoryItem[]; } export const historyUpdatedAction = createAction('explore/historyUpdated'); @@ -66,9 +67,9 @@ const updateRichHistoryState = ({ updatedQuery, deletedId }: SyncHistoryUpdatesO }; }; -const forEachExplorePane = (state: ExploreState, callback: (item: ExploreItemState, exploreId: ExploreId) => void) => { +const forEachExplorePane = (state: ExploreState, callback: (item: ExploreItemState, exploreId: string) => void) => { Object.entries(state.panes).forEach(([exploreId, item]) => { - callback(item!, exploreId as ExploreId); + item && callback(item, exploreId); }); }; @@ -118,18 +119,16 @@ export const deleteHistoryItem = (id: string): ThunkResult => { }; export const deleteRichHistory = (): ThunkResult => { - return async (dispatch) => { + return async (dispatch, getState) => { await deleteAllFromRichHistory(); - dispatch( - richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId: ExploreId.left }) - ); - dispatch( - richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId: ExploreId.right }) - ); + selectPanesEntries(getState()).forEach(([exploreId]) => { + dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId })); + dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId })); + }); }; }; -export const loadRichHistory = (exploreId: ExploreId): ThunkResult => { +export const loadRichHistory = (exploreId: string): ThunkResult => { return async (dispatch, getState) => { const filters = getState().explore.panes[exploreId]!.richHistorySearchFilters; if (filters) { @@ -139,7 +138,7 @@ export const loadRichHistory = (exploreId: ExploreId): ThunkResult => { }; }; -export const loadMoreRichHistory = (exploreId: ExploreId): ThunkResult => { +export const loadMoreRichHistory = (exploreId: string): ThunkResult => { return async (dispatch, getState) => { const currentFilters = getState().explore.panes[exploreId]?.richHistorySearchFilters; const currentRichHistory = getState().explore.panes[exploreId]?.richHistory; @@ -155,7 +154,7 @@ export const loadMoreRichHistory = (exploreId: ExploreId): ThunkResult => }; }; -export const clearRichHistoryResults = (exploreId: ExploreId): ThunkResult => { +export const clearRichHistoryResults = (exploreId: string): ThunkResult => { return async (dispatch) => { dispatch(richHistorySearchFiltersUpdatedAction({ filters: undefined, exploreId })); dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId })); @@ -186,10 +185,7 @@ export const updateHistorySettings = (settings: RichHistorySettings): ThunkResul /** * Assumed this can be called only when settings and filters are initialised */ -export const updateHistorySearchFilters = ( - exploreId: ExploreId, - filters: RichHistorySearchFilters -): ThunkResult => { +export const updateHistorySearchFilters = (exploreId: string, filters: RichHistorySearchFilters): ThunkResult => { return async (dispatch, getState) => { await dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters: { ...filters } })); const currentSettings = getState().explore.richHistorySettings!; diff --git a/public/app/features/explore/state/main.test.ts b/public/app/features/explore/state/main.test.ts index d6b2ec55469..240e9e7226f 100644 --- a/public/app/features/explore/state/main.test.ts +++ b/public/app/features/explore/state/main.test.ts @@ -7,7 +7,7 @@ import { PanelModel } from 'app/features/dashboard/state'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv'; -import { ExploreId, ExploreItemState, ExploreState } from '../../../types'; +import { ExploreItemState, ExploreState } from '../../../types'; import { exploreReducer, navigateToExplore, splitClose } from './main'; @@ -117,36 +117,6 @@ describe('navigateToExplore', () => { describe('Explore reducer', () => { describe('split view', () => { describe('split close', () => { - it('should move right pane to left when left is closed', () => { - const leftItemMock = { - containerWidth: 100, - } as unknown as ExploreItemState; - - const rightItemMock = { - containerWidth: 200, - } as unknown as ExploreItemState; - - const initialState = { - panes: { - left: leftItemMock, - right: rightItemMock, - }, - } as unknown as ExploreState; - - // closing left item - reducerTester() - .givenReducer(exploreReducer, initialState) - .whenActionIsDispatched(splitClose(ExploreId.left)) - .thenStateShouldEqual({ - evenSplitPanes: true, - largerExploreId: undefined, - panes: { - left: rightItemMock, - }, - maxedExploreId: undefined, - syncedTimes: false, - } as unknown as ExploreState); - }); it('should reset right pane when it is closed', () => { const leftItemMock = { containerWidth: 100, @@ -166,7 +136,7 @@ describe('Explore reducer', () => { // closing left item reducerTester() .givenReducer(exploreReducer, initialState) - .whenActionIsDispatched(splitClose(ExploreId.right)) + .whenActionIsDispatched(splitClose('right')) .thenStateShouldEqual({ evenSplitPanes: true, largerExploreId: undefined, @@ -193,7 +163,7 @@ describe('Explore reducer', () => { reducerTester() .givenReducer(exploreReducer, initialState) - .whenActionIsDispatched(splitClose(ExploreId.right)) + .whenActionIsDispatched(splitClose('right')) .thenStateShouldEqual({ evenSplitPanes: true, panes: { diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 837a5977aee..b0501535338 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -3,9 +3,9 @@ import { AnyAction } from 'redux'; import { SplitOpenOptions } from '@grafana/data'; import { DataSourceSrv, locationService } from '@grafana/runtime'; -import { GetExploreUrlArguments } from 'app/core/utils/explore'; +import { generateExploreId, GetExploreUrlArguments } from 'app/core/utils/explore'; import { PanelModel } from 'app/features/dashboard/state'; -import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore'; +import { ExploreItemState, ExploreState } from 'app/types/explore'; import { RichHistoryResults } from '../../../core/history/RichHistoryStorage'; import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes'; @@ -26,7 +26,7 @@ export interface SyncTimesPayload { } export const syncTimesAction = createAction('explore/syncTimes'); -export const richHistoryUpdatedAction = createAction<{ richHistoryResults: RichHistoryResults; exploreId: ExploreId }>( +export const richHistoryUpdatedAction = createAction<{ richHistoryResults: RichHistoryResults; exploreId: string }>( 'explore/richHistoryUpdated' ); export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction'); @@ -34,18 +34,18 @@ export const richHistoryLimitExceededAction = createAction('explore/richHistoryL export const richHistorySettingsUpdatedAction = createAction('explore/richHistorySettingsUpdated'); export const richHistorySearchFiltersUpdatedAction = createAction<{ - exploreId: ExploreId; + exploreId: string; filters?: RichHistorySearchFilters; }>('explore/richHistorySearchFiltersUpdatedAction'); export const saveCorrelationsAction = createAction('explore/saveCorrelationsAction'); export const splitSizeUpdateAction = createAction<{ - largerExploreId?: ExploreId; + largerExploreId?: string; }>('explore/splitSizeUpdateAction'); export const maximizePaneAction = createAction<{ - exploreId?: ExploreId; + exploreId?: string; }>('explore/maximizePaneAction'); export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction'); @@ -53,8 +53,7 @@ export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction') /** * Close the pane with the given id. */ -type SplitCloseActionPayload = ExploreId; -export const splitClose = createAction('explore/splitClose'); +export const splitClose = createAction('explore/splitClose'); export interface SetPaneStateActionPayload { [itemId: string]: Partial; @@ -64,27 +63,31 @@ export const setPaneState = createAction('explore/set export const clearPanes = createAction('explore/clearPanes'); /** - * Opens a new split pane. It either copies existing state of the left pane + * Opens a new split pane. It either copies existing state of an already present pane * or uses values from options arg. * * TODO: this can be improved by better inferring fallback values. */ export const splitOpen = createAsyncThunk( 'explore/splitOpen', - async (options: SplitOpenOptions | undefined, { getState, dispatch }) => { - const leftState = getState().explore.panes.left; + async (options: SplitOpenOptions | undefined, { getState, dispatch, requestId }) => { + // we currently support showing only 2 panes in explore, so if this action is dispatched we know it has been dispatched from the "first" pane. + const originState = Object.values(getState().explore.panes)[0]; - const queries = options?.queries ?? (options?.query ? [options?.query] : leftState?.queries || []); + const queries = options?.queries ?? (options?.query ? [options?.query] : originState?.queries || []); await dispatch( initializeExplore({ - exploreId: ExploreId.right, - datasource: options?.datasourceUid || leftState?.datasourceInstance?.getRef(), + exploreId: requestId, + datasource: options?.datasourceUid || originState?.datasourceInstance?.getRef(), queries: withUniqueRefIds(queries), - range: options?.range || leftState?.range.raw || DEFAULT_RANGE, - panelsState: options?.panelsState || leftState?.panelsState, + range: options?.range || originState?.range.raw || DEFAULT_RANGE, + panelsState: options?.panelsState || originState?.panelsState, }) ); + }, + { + idGenerator: generateExploreId, } ); @@ -138,9 +141,8 @@ export const initialExploreState: ExploreState = { */ export const exploreReducer = (state = initialExploreState, action: AnyAction): ExploreState => { if (splitClose.match(action)) { - const panes = { - left: action.payload === ExploreId.left ? state.panes.right : state.panes.left, - }; + const { [action.payload]: _, ...panes } = { ...state.panes }; + return { ...state, panes, @@ -218,18 +220,23 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): ...state, panes: { ...state.panes, - right: initialExploreItemState, + [action.meta.requestId]: initialExploreItemState, }, }; } if (initializeExplore.pending.match(action)) { + const initialPanes = Object.entries(state.panes); + const before = initialPanes.slice(0, action.meta.arg.position); + const after = initialPanes.slice(before.length); + const panes = [...before, [action.meta.arg.exploreId, initialExploreItemState] as const, ...after].reduce( + (acc, [id, pane]) => ({ ...acc, [id]: pane }), + {} + ); + return { ...state, - panes: { - ...state.panes, - [action.meta.arg.exploreId]: initialExploreItemState, - }, + panes, }; } @@ -240,17 +247,15 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): }; } - const exploreId: ExploreId | undefined = action.payload?.exploreId; + const exploreId: string | undefined = action.payload?.exploreId; if (typeof exploreId === 'string') { return { ...state, - panes: Object.entries(state.panes).reduce((acc, [id, pane]) => { - if (id === exploreId) { - acc[id] = paneReducer(pane, action); - } else { - acc[id as ExploreId] = pane; - } - return acc; + panes: Object.entries(state.panes).reduce((acc, [id, pane]) => { + return { + ...acc, + [id]: id === exploreId ? paneReducer(pane, action) : pane, + }; }, {}), }; } diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index 975c2e48a9d..24d5601b5cd 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -16,7 +16,7 @@ import { } from '@grafana/data'; import { config } from '@grafana/runtime'; import { DataQuery, DataSourceRef } from '@grafana/schema'; -import { createAsyncThunk, ExploreId, ExploreItemState, StoreState, ThunkDispatch } from 'app/types'; +import { createAsyncThunk, ExploreItemState, StoreState, ThunkDispatch } from 'app/types'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { configureStore } from '../../../store/configureStore'; @@ -51,7 +51,7 @@ import { makeExplorePaneState } from './utils'; const { testRange, defaultInitialState } = createDefaultInitialState(); -const exploreId = ExploreId.left; +const exploreId = 'left'; const datasources: DataSourceApi[] = [ { name: 'testDs', @@ -158,7 +158,7 @@ describe('runQueries', () => { const { dispatch, getState } = setupTests(); setupQueryResponse(getState()); await dispatch(saveCorrelationsAction([])); - await dispatch(runQueries({ exploreId: ExploreId.left })); + await dispatch(runQueries({ exploreId: 'left' })); expect(getState().explore.panes.left!.showMetrics).toBeTruthy(); expect(getState().explore.panes.left!.graphResult).toBeDefined(); }); @@ -167,7 +167,7 @@ describe('runQueries', () => { const { dispatch, getState } = setupTests(); setupQueryResponse(getState()); dispatch(saveCorrelationsAction([])); - dispatch(runQueries({ exploreId: ExploreId.left })); + dispatch(runQueries({ exploreId: 'left' })); const state = getState().explore.panes.left!; expect(state.queryResponse.request?.requestId).toBe('explore_left'); @@ -187,7 +187,7 @@ describe('runQueries', () => { const leftDatasourceInstance = assertIsDefined(getState().explore.panes.left!.datasourceInstance); jest.mocked(leftDatasourceInstance.query).mockReturnValueOnce(EMPTY); await dispatch(saveCorrelationsAction([])); - await dispatch(runQueries({ exploreId: ExploreId.left })); + await dispatch(runQueries({ exploreId: 'left' })); await new Promise((resolve) => setTimeout(() => resolve(''), 500)); expect(getState().explore.panes.left!.queryResponse.state).toBe(LoadingState.Done); }); @@ -195,7 +195,7 @@ describe('runQueries', () => { it('shows results only after correlations are loaded', async () => { const { dispatch, getState } = setupTests(); setupQueryResponse(getState()); - await dispatch(runQueries({ exploreId: ExploreId.left })); + await dispatch(runQueries({ exploreId: 'left' })); expect(getState().explore.panes.left!.graphResult).not.toBeDefined(); await dispatch(saveCorrelationsAction([])); expect(getState().explore.panes.left!.graphResult).toBeDefined(); @@ -206,7 +206,7 @@ describe('running queries', () => { it('should cancel running query when cancelQueries is dispatched', async () => { const unsubscribable = interval(1000); unsubscribable.subscribe(); - const exploreId = ExploreId.left; + const exploreId = 'left'; const initialState = { explore: { panes: { @@ -274,13 +274,13 @@ describe('changeQueries', () => { await dispatch( changeQueries({ queries: [{ refId: 'A', datasource: datasources[1].getRef() }], - exploreId: ExploreId.left, + exploreId: 'left', }) ); expect(actions.changeQueriesAction).not.toHaveBeenCalled(); expect(actions.importQueries).toHaveBeenCalledWith( - ExploreId.left, + 'left', originalQueries, datasources[0], datasources[1], @@ -308,7 +308,7 @@ describe('changeQueries', () => { await dispatch( changeQueries({ queries: [{ refId: 'A', datasource: datasources[0].getRef(), queryType: 'someValue' }], - exploreId: ExploreId.left, + exploreId: 'left', }) ); @@ -337,7 +337,7 @@ describe('changeQueries', () => { await dispatch( changeQueries({ queries: [{ refId: 'A', datasource: datasources[1].getRef() }], - exploreId: ExploreId.left, + exploreId: 'left', }) ); @@ -362,7 +362,7 @@ describe('changeQueries', () => { await dispatch( changeQueries({ queries: [{ refId: 'A', datasource: datasources[0].getRef(), queryType: 'someValue' }], - exploreId: ExploreId.left, + exploreId: 'left', }) ); @@ -400,7 +400,7 @@ describe('changeQueries', () => { await dispatch( changeQueries({ queries: [originalQueries[0]], - exploreId: ExploreId.left, + exploreId: 'left', }) ); @@ -425,7 +425,7 @@ describe('importing queries', () => { await dispatch( importQueries( - ExploreId.left, + 'left', [ { datasource: { type: 'postgresql', uid: 'ds1' }, refId: 'refId_A' }, { datasource: { type: 'postgresql', uid: 'ds1' }, refId: 'refId_B' }, @@ -606,7 +606,7 @@ describe('reducer', () => { reducerTester() .givenReducer(queryReducer, initialState) - .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left })) + .whenActionIsDispatched(scanStartAction({ exploreId: 'left' })) .thenStateShouldEqual({ ...initialState, scanning: true, @@ -621,7 +621,7 @@ describe('reducer', () => { reducerTester() .givenReducer(queryReducer, initialState) - .whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left })) + .whenActionIsDispatched(scanStopAction({ exploreId: 'left' })) .thenStateShouldEqual({ ...initialState, scanning: false, @@ -638,7 +638,7 @@ describe('reducer', () => { } as unknown as ExploreItemState) .whenActionIsDispatched( addQueryRowAction({ - exploreId: ExploreId.left, + exploreId: 'left', query: { refId: 'A', key: 'mockKey' }, index: 0, }) @@ -655,7 +655,7 @@ describe('reducer', () => { } as unknown as ExploreItemState) .whenActionIsDispatched( addQueryRowAction({ - exploreId: ExploreId.left, + exploreId: 'left', query: { refId: 'B', key: 'mockKey', datasource: { type: 'loki' } }, index: 0, }) @@ -688,7 +688,7 @@ describe('reducer', () => { }, } as unknown as Partial); - await dispatch(addResultsToCache(ExploreId.left)); + await dispatch(addResultsToCache('left')); expect(getState().explore.panes.left!.cache).toEqual([ { key: 'from=1621348027000&to=1621348050000', value: { series: [{ name: 'test name' }], state: 'Done' } }, @@ -709,7 +709,7 @@ describe('reducer', () => { }, } as unknown as Partial); - await dispatch(addResultsToCache(ExploreId.left)); + await dispatch(addResultsToCache('left')); expect(getState().explore.panes.left!.cache).toEqual([]); }); @@ -737,7 +737,7 @@ describe('reducer', () => { }, } as unknown as Partial); - await dispatch(addResultsToCache(ExploreId.left)); + await dispatch(addResultsToCache('left')); expect(getState().explore.panes.left!.cache).toHaveLength(1); expect(getState().explore.panes.left!.cache).toEqual([ @@ -763,7 +763,7 @@ describe('reducer', () => { }, } as unknown as Partial); - await dispatch(clearCache(ExploreId.left)); + await dispatch(clearCache('left')); expect(getState().explore.panes.left!.cache).toEqual([]); }); @@ -822,7 +822,7 @@ describe('reducer', () => { }); it('should cancel any unfinished supplementary queries when a new query is run', async () => { - dispatch(runQueries({ exploreId: ExploreId.left })); + dispatch(runQueries({ exploreId: 'left' })); // first query is run automatically // loading in progress - subscriptions for both supplementary queries are created, not cleaned up yet expect(unsubscribes).toHaveLength(2); @@ -830,7 +830,7 @@ describe('reducer', () => { expect(unsubscribes[1]).not.toBeCalled(); setupQueryResponse(getState()); - dispatch(runQueries({ exploreId: ExploreId.left })); + dispatch(runQueries({ exploreId: 'left' })); // a new query is run while supplementary queries are not resolve yet... expect(unsubscribes[0]).toBeCalled(); expect(unsubscribes[1]).toBeCalled(); @@ -841,12 +841,12 @@ describe('reducer', () => { }); it('should cancel all supported supplementary queries when the main query is canceled', () => { - dispatch(runQueries({ exploreId: ExploreId.left })); + dispatch(runQueries({ exploreId: 'left' })); expect(unsubscribes).toHaveLength(2); expect(unsubscribes[0]).not.toBeCalled(); expect(unsubscribes[1]).not.toBeCalled(); - dispatch(cancelQueries(ExploreId.left)); + dispatch(cancelQueries('left')); expect(unsubscribes).toHaveLength(2); expect(unsubscribes[0]).toBeCalled(); expect(unsubscribes[1]).toBeCalled(); @@ -858,7 +858,7 @@ describe('reducer', () => { }); it('should load supplementary queries after running the query', () => { - dispatch(runQueries({ exploreId: ExploreId.left })); + dispatch(runQueries({ exploreId: 'left' })); expect(unsubscribes).toHaveLength(2); }); @@ -866,7 +866,7 @@ describe('reducer', () => { mockDataProvider = () => { return of({ state: LoadingState.Loading, error: undefined, data: [] }); }; - dispatch(runQueries({ exploreId: ExploreId.left })); + dispatch(runQueries({ exploreId: 'left' })); for (const type of supplementaryQueryTypes) { expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeDefined(); @@ -879,7 +879,7 @@ describe('reducer', () => { expect(getState().explore.panes.left!.supplementaryQueries[type].dataProvider).toBeDefined(); } - dispatch(cancelQueries(ExploreId.left)); + dispatch(cancelQueries('left')); for (const type of supplementaryQueryTypes) { expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeUndefined(); expect(getState().explore.panes.left!.supplementaryQueries[type].data).toBeUndefined(); @@ -893,7 +893,7 @@ describe('reducer', () => { { state: LoadingState.Done, error: undefined, data: [{}] } ); }; - dispatch(runQueries({ exploreId: ExploreId.left })); + dispatch(runQueries({ exploreId: 'left' })); for (const types of supplementaryQueryTypes) { expect(getState().explore.panes.left!.supplementaryQueries[types].data).toBeDefined(); @@ -901,7 +901,7 @@ describe('reducer', () => { expect(getState().explore.panes.left!.supplementaryQueries[types].dataProvider).toBeDefined(); } - dispatch(cancelQueries(ExploreId.left)); + dispatch(cancelQueries('left')); for (const types of supplementaryQueryTypes) { expect(getState().explore.panes.left!.supplementaryQueries[types].data).toBeDefined(); @@ -915,14 +915,14 @@ describe('reducer', () => { return of({ state: LoadingState.Done, error: undefined, data: [{}] }); }; // turn logs volume off (but keep logs sample on) - dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsVolume)); + dispatch(setSupplementaryQueryEnabled('left', false, SupplementaryQueryType.LogsVolume)); expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].enabled).toBe( false ); expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsSample].enabled).toBe(true); // verify that if we run a query, it will: 1) not do logs volume, 2) do logs sample 3) provider will still be set for both - dispatch(runQueries({ exploreId: ExploreId.left })); + dispatch(runQueries({ exploreId: 'left' })); expect( getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].data @@ -945,11 +945,11 @@ describe('reducer', () => { it('load data of supplementary query that gets enabled', async () => { // first we start with both supplementary queries disabled - dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsVolume)); - dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsSample)); + dispatch(setSupplementaryQueryEnabled('left', false, SupplementaryQueryType.LogsVolume)); + dispatch(setSupplementaryQueryEnabled('left', false, SupplementaryQueryType.LogsSample)); // runQueries sets up providers, but does not run queries - dispatch(runQueries({ exploreId: ExploreId.left })); + dispatch(runQueries({ exploreId: 'left' })); expect( getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].dataProvider ).toBeDefined(); @@ -958,7 +958,7 @@ describe('reducer', () => { ).toBeDefined(); // we turn 1 supplementary query (logs volume) on - dispatch(setSupplementaryQueryEnabled(ExploreId.left, true, SupplementaryQueryType.LogsVolume)); + dispatch(setSupplementaryQueryEnabled('left', true, SupplementaryQueryType.LogsVolume)); // verify it was turned on expect(getState().explore.panes.left!.supplementaryQueries[SupplementaryQueryType.LogsVolume].enabled).toBe(true); @@ -984,8 +984,8 @@ describe('reducer', () => { ...defaultInitialState, explore: { panes: { - [ExploreId.left]: { - ...defaultInitialState.explore.panes[ExploreId.left], + ['left']: { + ...defaultInitialState.explore.panes['left'], queryResponse: { state: LoadingState.Streaming, }, @@ -997,12 +997,12 @@ describe('reducer', () => { }, }, } as unknown as Partial); - expect(getState().explore.panes[ExploreId.left]?.logsResult?.rows.length).toBe(logRows.length); + expect(getState().explore.panes['left']?.logsResult?.rows.length).toBe(logRows.length); - await dispatch(clearLogs({ exploreId: ExploreId.left })); + await dispatch(clearLogs({ exploreId: 'left' })); - expect(getState().explore.panes[ExploreId.left]?.logsResult?.rows.length).toBe(0); - expect(getState().explore.panes[ExploreId.left]?.clearedAtIndex).toBe(logRows.length - 1); + expect(getState().explore.panes['left']?.logsResult?.rows.length).toBe(0); + expect(getState().explore.panes['left']?.clearedAtIndex).toBe(logRows.length - 1); }); it('should filter new log rows', async () => { @@ -1014,8 +1014,8 @@ describe('reducer', () => { ...defaultInitialState, explore: { panes: { - [ExploreId.left]: { - ...defaultInitialState.explore.panes[ExploreId.left], + ['left']: { + ...defaultInitialState.explore.panes['left'], isLive: true, queryResponse: { state: LoadingState.Streaming, @@ -1029,12 +1029,12 @@ describe('reducer', () => { }, } as unknown as Partial); - expect(getState().explore.panes[ExploreId.left]?.logsResult?.rows.length).toBe(oldLogRows.length); + expect(getState().explore.panes['left']?.logsResult?.rows.length).toBe(oldLogRows.length); - await dispatch(clearLogs({ exploreId: ExploreId.left })); + await dispatch(clearLogs({ exploreId: 'left' })); await dispatch( queryStreamUpdatedAction({ - exploreId: ExploreId.left, + exploreId: 'left', response: { request: true, traceFrames: [], @@ -1049,8 +1049,8 @@ describe('reducer', () => { } as unknown as QueryEndedPayload) ); - expect(getState().explore.panes[ExploreId.left]?.logsResult?.rows.length).toBe(newLogRows.length); - expect(getState().explore.panes[ExploreId.left]?.clearedAtIndex).toBe(oldLogRows.length - 1); + expect(getState().explore.panes['left']?.logsResult?.rows.length).toBe(newLogRows.length); + expect(getState().explore.panes['left']?.clearedAtIndex).toBe(oldLogRows.length - 1); }); }); }); diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 5fb64eb154f..2be33daa966 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -47,7 +47,7 @@ import { ThunkDispatch, ThunkResult, } from 'app/types'; -import { ExploreId, ExploreState, QueryOptions, SupplementaryQueries } from 'app/types/explore'; +import { ExploreState, QueryOptions, SupplementaryQueries } from 'app/types/explore'; import { notifyApp } from '../../../core/actions'; import { createErrorNotification } from '../../../core/copy/appNotification'; @@ -66,7 +66,7 @@ import { createCacheKey, filterLogRowsByIndex, getResultsFromCache } from './uti /** * Derives from explore state if a given Explore pane is waiting for more data to be received */ -export const selectIsWaitingForData = (exploreId: ExploreId) => { +export const selectIsWaitingForData = (exploreId: string) => { return (state: StoreState) => { const panelState = state.explore.panes[exploreId]; if (!panelState) { @@ -83,7 +83,7 @@ export const selectIsWaitingForData = (exploreId: ExploreId) => { * Adds a query row after the row with the given index. */ export interface AddQueryRowPayload { - exploreId: ExploreId; + exploreId: string; index: number; query: DataQuery; } @@ -94,7 +94,7 @@ export const addQueryRowAction = createAction('explore/addQu * If `override` is reset the query modifications and run the queries. Use this to set queries via a link. */ export interface ChangeQueriesPayload { - exploreId: ExploreId; + exploreId: string; queries: DataQuery[]; } export const changeQueriesAction = createAction('explore/changeQueries'); @@ -103,18 +103,18 @@ export const changeQueriesAction = createAction('explore/c * Cancel running queries. */ export interface CancelQueriesPayload { - exploreId: ExploreId; + exploreId: string; } export const cancelQueriesAction = createAction('explore/cancelQueries'); export interface QueriesImportedPayload { - exploreId: ExploreId; + exploreId: string; queries: DataQuery[]; } export const queriesImportedAction = createAction('explore/queriesImported'); export interface QueryStoreSubscriptionPayload { - exploreId: ExploreId; + exploreId: string; querySubscription: Unsubscribable; } @@ -123,19 +123,19 @@ export const queryStoreSubscriptionAction = createAction('explore/setSupplementaryQueryEnabledAction'); export interface StoreSupplementaryQueryDataProvider { - exploreId: ExploreId; + exploreId: string; dataProvider?: Observable; type: SupplementaryQueryType; } export interface CleanSupplementaryQueryDataProvider { - exploreId: ExploreId; + exploreId: string; type: SupplementaryQueryType; } @@ -150,12 +150,12 @@ export const cleanSupplementaryQueryDataProviderAction = createAction( +export const cleanSupplementaryQueryAction = createAction<{ exploreId: string; type: SupplementaryQueryType }>( 'explore/cleanSupplementaryQueryAction' ); export interface StoreSupplementaryQueryDataSubscriptionPayload { - exploreId: ExploreId; + exploreId: string; dataSubscription?: SubscriptionLike; type: SupplementaryQueryType; } @@ -171,13 +171,13 @@ const storeSupplementaryQueryDataSubscriptionAction = createAction('explore/updateSupplementaryQueryDataAction'); export interface QueryEndedPayload { - exploreId: ExploreId; + exploreId: string; response: ExplorePanelData; } export const queryStreamUpdatedAction = createAction('explore/queryStreamUpdated'); @@ -186,25 +186,25 @@ export const queryStreamUpdatedAction = createAction('explore * Reset queries to the given queries. Any modifications will be discarded. */ export interface SetQueriesPayload { - exploreId: ExploreId; + exploreId: string; queries: DataQuery[]; } export const setQueriesAction = createAction('explore/setQueries'); export interface ChangeLoadingStatePayload { - exploreId: ExploreId; + exploreId: string; loadingState: LoadingState; } export const changeLoadingStateAction = createAction('changeLoadingState'); export interface SetPausedStatePayload { - exploreId: ExploreId; + exploreId: string; isPaused: boolean; } export const setPausedStateAction = createAction('explore/setPausedState'); export interface ClearLogsPayload { - exploreId: ExploreId; + exploreId: string; } export const clearLogs = createAction('explore/clearLogs'); /** @@ -213,7 +213,7 @@ export const clearLogs = createAction('explore/clearLogs'); * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range */ export interface ScanStartPayload { - exploreId: ExploreId; + exploreId: string; } export const scanStartAction = createAction('explore/scanStart'); @@ -221,7 +221,7 @@ export const scanStartAction = createAction('explore/scanStart * Stop any scanning for more results. */ export interface ScanStopPayload { - exploreId: ExploreId; + exploreId: string; } export const scanStopAction = createAction('explore/scanStop'); @@ -230,7 +230,7 @@ export const scanStopAction = createAction('explore/scanStop'); * This is currently used to cache last 5 query results for log queries run from logs navigation (pagination). */ export interface AddResultsToCachePayload { - exploreId: ExploreId; + exploreId: string; cacheKey: string; queryResponse: ExplorePanelData; } @@ -240,14 +240,14 @@ export const addResultsToCacheAction = createAction('e * Clears cache. */ export interface ClearCachePayload { - exploreId: ExploreId; + exploreId: string; } export const clearCacheAction = createAction('explore/clearCache'); /** * Adds a query row after the row with the given index. */ -export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult { +export function addQueryRow(exploreId: string, index: number): ThunkResult { return async (dispatch, getState) => { const queries = getState().explore.panes[exploreId]!.queries; let datasourceOverride = undefined; @@ -270,7 +270,7 @@ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult { +export function cancelQueries(exploreId: string): ThunkResult { return (dispatch, getState) => { dispatch(scanStopAction({ exploreId })); dispatch(cancelQueriesAction({ exploreId })); @@ -352,7 +352,7 @@ export const changeQueries = createAsyncThunk( * @param targetDataSource */ export const importQueries = ( - exploreId: ExploreId, + exploreId: string, queries: DataQuery[], sourceDataSource: DataSourceApi | undefined | null, targetDataSource: DataSourceApi, @@ -425,7 +425,7 @@ export const importQueries = ( * @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`. */ export function modifyQueries( - exploreId: ExploreId, + exploreId: string, modification: QueryFixAction, modifier: (query: DataQuery, modification: QueryFixAction) => Promise ): ThunkResult { @@ -453,7 +453,7 @@ async function handleHistory( history: Array>, datasource: DataSourceApi, queries: DataQuery[], - exploreId: ExploreId + exploreId: string ) { const datasourceId = datasource.meta.id; const nextHistory = updateHistory(history, datasourceId, queries); @@ -465,12 +465,12 @@ async function handleHistory( // used filters. Instead, we refresh the query history list. // TODO: run only if Query History list is opened (#47252) for (const exploreId in state.panes) { - await dispatch(loadRichHistory(exploreId as ExploreId)); + await dispatch(loadRichHistory(exploreId)); } } interface RunQueriesOptions { - exploreId: ExploreId; + exploreId: string; preserveCache?: boolean; } /** @@ -653,7 +653,7 @@ const groupDataQueries = async (datasources: DataQuery[], scopedVars: ScopedVars }; type HandleSupplementaryQueriesOptions = { - exploreId: ExploreId; + exploreId: string; transaction: QueryTransaction; datasourceInstance: DataSourceApi; newQuerySource: Observable; @@ -772,7 +772,7 @@ function canReuseSupplementaryQueryData( * Reset queries to the given queries. Any modifications will be discarded. * Use this action for clicks on query examples. Triggers a query run. */ -export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult { +export function setQueries(exploreId: string, rawQueries: DataQuery[]): ThunkResult { return (dispatch, getState) => { // Inject react keys into query objects const queries = getState().explore.panes[exploreId]!.queries; @@ -787,7 +787,7 @@ export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): Thunk * @param exploreId Explore area * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range */ -export function scanStart(exploreId: ExploreId): ThunkResult { +export function scanStart(exploreId: string): ThunkResult { return (dispatch, getState) => { // Register the scanner dispatch(scanStartAction({ exploreId })); @@ -799,7 +799,7 @@ export function scanStart(exploreId: ExploreId): ThunkResult { }; } -export function addResultsToCache(exploreId: ExploreId): ThunkResult { +export function addResultsToCache(exploreId: string): ThunkResult { return (dispatch, getState) => { const queryResponse = getState().explore.panes[exploreId]!.queryResponse; const absoluteRange = getState().explore.panes[exploreId]!.absoluteRange; @@ -812,7 +812,7 @@ export function addResultsToCache(exploreId: ExploreId): ThunkResult { }; } -export function clearCache(exploreId: ExploreId): ThunkResult { +export function clearCache(exploreId: string): ThunkResult { return (dispatch, getState) => { dispatch(clearCacheAction({ exploreId })); }; @@ -821,7 +821,7 @@ export function clearCache(exploreId: ExploreId): ThunkResult { /** * Initializes loading logs volume data and stores emitted value. */ -export function loadSupplementaryQueryData(exploreId: ExploreId, type: SupplementaryQueryType): ThunkResult { +export function loadSupplementaryQueryData(exploreId: string, type: SupplementaryQueryType): ThunkResult { return (dispatch, getState) => { const { supplementaryQueries } = getState().explore.panes[exploreId]!; const dataProvider = supplementaryQueries[type].dataProvider; @@ -844,7 +844,7 @@ export function loadSupplementaryQueryData(exploreId: ExploreId, type: Supplemen } export function setSupplementaryQueryEnabled( - exploreId: ExploreId, + exploreId: string, enabled: boolean, type: SupplementaryQueryType ): ThunkResult { diff --git a/public/app/features/explore/state/selectors.test.ts b/public/app/features/explore/state/selectors.test.ts deleted file mode 100644 index a774ff9a377..00000000000 --- a/public/app/features/explore/state/selectors.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { initialExploreState } from './main'; -import { selectOrderedExplorePanes } from './selectors'; -import { makeExplorePaneState } from './utils'; - -describe('getOrderedExplorePanes', () => { - it('returns a panes object with entries in the correct order', () => { - const selectorResult = selectOrderedExplorePanes({ - explore: { - ...initialExploreState, - panes: { - right: makeExplorePaneState(), - left: makeExplorePaneState(), - }, - }, - }); - - expect(Object.keys(selectorResult)).toEqual(['left', 'right']); - }); -}); diff --git a/public/app/features/explore/state/selectors.ts b/public/app/features/explore/state/selectors.ts index 18950fb1ca5..938a1ad31c2 100644 --- a/public/app/features/explore/state/selectors.ts +++ b/public/app/features/explore/state/selectors.ts @@ -1,26 +1,14 @@ import { createSelector } from '@reduxjs/toolkit'; -import { ExploreId, ExploreState, StoreState } from 'app/types'; +import { ExploreItemState, StoreState } from 'app/types'; export const selectPanes = (state: Pick) => state.explore.panes; -/** - * Explore renders panes by iterating over the panes object. This selector ensures that entries in the returned panes object - * are in the correct order. - */ -export const selectOrderedExplorePanes = createSelector(selectPanes, (panes) => { - const orderedPanes: ExploreState['panes'] = {}; +export const selectPanesEntries = createSelector< + [(state: Pick) => Record], + Array<[string, ExploreItemState]> +>(selectPanes, Object.entries); - if (panes.left) { - orderedPanes.left = panes.left; - } - if (panes.right) { - orderedPanes.right = panes.right; - } - return orderedPanes; -}); +export const isSplit = createSelector(selectPanesEntries, (panes) => panes.length > 1); -export const isSplit = createSelector(selectPanes, (panes) => Object.keys(panes).length > 1); - -export const getExploreItemSelector = (exploreId: ExploreId) => - createSelector(selectPanes, (panes) => panes[exploreId]); +export const getExploreItemSelector = (exploreId: string) => createSelector(selectPanes, (panes) => panes[exploreId]); diff --git a/public/app/features/explore/state/time.test.ts b/public/app/features/explore/state/time.test.ts index 3b74ab41018..0bd865931b6 100644 --- a/public/app/features/explore/state/time.test.ts +++ b/public/app/features/explore/state/time.test.ts @@ -2,7 +2,7 @@ import { reducerTester } from 'test/core/redux/reducerTester'; import { dateTime } from '@grafana/data'; import { configureStore } from 'app/store/configureStore'; -import { ExploreId, ExploreItemState } from 'app/types'; +import { ExploreItemState } from 'app/types'; import { createDefaultInitialState } from './helpers'; import { changeRangeAction, timeReducer, updateTime } from './time'; @@ -30,7 +30,7 @@ describe('Explore item reducer', () => { describe('When time is updated', () => { it('Time service is re-initialized and template service is updated with the new time range', async () => { const { dispatch } = configureStore(createDefaultInitialState().defaultInitialState as any); - dispatch(updateTime({ exploreId: ExploreId.left })); + dispatch(updateTime({ exploreId: 'left' })); expect(mockTimeSrv.init).toBeCalled(); expect(mockTemplateSrv.updateTimeRange).toBeCalledWith(MOCK_TIME_RANGE); }); @@ -46,7 +46,7 @@ describe('Explore item reducer', () => { } as unknown as ExploreItemState) .whenActionIsDispatched( changeRangeAction({ - exploreId: ExploreId.left, + exploreId: 'left', absoluteRange: { from: 1546297200000, to: 1546383600000 }, range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } }, }) diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts index 0d7056aed17..8b720e9b626 100644 --- a/public/app/features/explore/state/time.ts +++ b/public/app/features/explore/state/time.ts @@ -7,7 +7,6 @@ import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/co import { sortLogsResult } from 'app/features/logs/utils'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; import { ExploreItemState, ThunkResult } from 'app/types'; -import { ExploreId } from 'app/types/explore'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { TimeModel } from '../../dashboard/state/TimeModel'; @@ -20,7 +19,7 @@ import { runQueries } from './query'; // export interface ChangeRangePayload { - exploreId: ExploreId; + exploreId: string; range: TimeRange; absoluteRange: AbsoluteTimeRange; } @@ -30,13 +29,13 @@ export const changeRangeAction = createAction('explore/chang * Change the time range of Explore. Usually called from the Timepicker or a graph interaction. */ export interface ChangeRefreshIntervalPayload { - exploreId: ExploreId; + exploreId: string; refreshInterval: string; } export const changeRefreshInterval = createAction('explore/changeRefreshInterval'); export const updateTimeRange = (options: { - exploreId: ExploreId; + exploreId: string; rawRange?: RawTimeRange; absoluteRange?: AbsoluteTimeRange; }): ThunkResult => { @@ -44,8 +43,8 @@ export const updateTimeRange = (options: { const { syncedTimes } = getState().explore; if (syncedTimes) { Object.keys(getState().explore.panes).forEach((exploreId) => { - dispatch(updateTime({ ...options, exploreId: exploreId as ExploreId })); - dispatch(runQueries({ exploreId: exploreId as ExploreId, preserveCache: true })); + dispatch(updateTime({ ...options, exploreId })); + dispatch(runQueries({ exploreId: exploreId, preserveCache: true })); }); } else { dispatch(updateTime({ ...options })); @@ -55,7 +54,7 @@ export const updateTimeRange = (options: { }; export const updateTime = (config: { - exploreId: ExploreId; + exploreId: string; rawRange?: RawTimeRange; absoluteRange?: AbsoluteTimeRange; }): ThunkResult => { @@ -104,14 +103,14 @@ export const updateTime = (config: { * Syncs time interval, if they are not synced on both panels in a split mode. * Unsyncs time interval, if they are synced on both panels in a split mode. */ -export function syncTimes(exploreId: ExploreId): ThunkResult { +export function syncTimes(exploreId: string): ThunkResult { return (dispatch, getState) => { const range = getState().explore.panes[exploreId]!.range.raw; Object.keys(getState().explore.panes) .filter((key) => key !== exploreId) .forEach((exploreId) => { - dispatch(updateTimeRange({ exploreId: exploreId as ExploreId, rawRange: range })); + dispatch(updateTimeRange({ exploreId, rawRange: range })); }); const isTimeSynced = getState().explore.syncedTimes; @@ -132,7 +131,7 @@ export function makeAbsoluteTime(): ThunkResult { Object.entries(getState().explore.panes).forEach(([exploreId, exploreItemState]) => { const range = getTimeRange(timeZone, exploreItemState!.range.raw, fiscalYearStartMonth); const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; - dispatch(updateTime({ exploreId: exploreId as ExploreId, absoluteRange })); + dispatch(updateTime({ exploreId, absoluteRange })); }); }; } diff --git a/public/app/features/explore/useLiveTailControls.ts b/public/app/features/explore/useLiveTailControls.ts index 80625c8f4ef..adf7b555651 100644 --- a/public/app/features/explore/useLiveTailControls.ts +++ b/public/app/features/explore/useLiveTailControls.ts @@ -3,15 +3,13 @@ import React, { useCallback } from 'react'; import { RefreshPicker } from '@grafana/ui'; import { useDispatch } from 'app/types'; -import { ExploreId } from '../../types'; - import { setPausedStateAction, runQueries, clearLogs } from './state/query'; import { changeRefreshInterval } from './state/time'; /** * Hook that gives you all the functions needed to control the live tailing. */ -export function useLiveTailControls(exploreId: ExploreId) { +export function useLiveTailControls(exploreId: string) { const dispatch = useDispatch(); const pause = useCallback(() => { @@ -52,7 +50,7 @@ export function useLiveTailControls(exploreId: ExploreId) { } type Props = { - exploreId: ExploreId; + exploreId: string; children: (controls: ReturnType) => React.ReactElement; }; diff --git a/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx index 077a5ca6b8a..f31b8c26283 100644 --- a/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx @@ -13,7 +13,6 @@ import { TypeaheadOutput, withTheme2, } from '@grafana/ui'; -import { ExploreId } from 'app/types'; // Utils & Services // dom also includes Element polyfills @@ -30,7 +29,7 @@ export interface CloudWatchLogsQueryFieldProps absoluteRange: AbsoluteTimeRange; onLabelsRefresh?: () => void; ExtraFieldElement?: ReactNode; - exploreId: ExploreId; + exploreId: string; query: CloudWatchLogsQuery; } const plugins: Array> = [ diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index b3d53fa7e58..7d8abc9d159 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -15,21 +15,13 @@ import { DataQueryResponse, ExplorePanelsState, SupplementaryQueryType, + UrlQueryMap, } from '@grafana/data'; import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes'; import { CorrelationData } from '../features/correlations/useCorrelations'; -export enum ExploreId { - left = 'left', - right = 'right', -} - -export type ExploreQueryParams = { - left?: string; - right?: string; -}; - +export type ExploreQueryParams = UrlQueryMap; /** * Global Explore state */ @@ -39,13 +31,7 @@ export interface ExploreState { */ syncedTimes: boolean; - // This being optional wouldn't be needed with noUncheckedIndexedAccess set to true, but it cause more than 5k errors currently. - // In order to be safe, we declare each item as pssobly undefined to force existence checks. - // This will have the side effect of also forcing undefined checks when iterating over this object entries, but - // it's better to error on the safer side. - panes: { - [paneId in ExploreId]?: ExploreItemState; - }; + panes: Record; correlations?: CorrelationData[]; diff --git a/public/test/helpers/TestProvider.tsx b/public/test/helpers/TestProvider.tsx index 739af4c454d..b7b232f041a 100644 --- a/public/test/helpers/TestProvider.tsx +++ b/public/test/helpers/TestProvider.tsx @@ -20,12 +20,17 @@ export interface Props { * Wrapps component in redux store provider, Router and GrafanaContext */ export function TestProvider(props: Props) { - const { store = configureStore(props.storeState), grafanaContext = getGrafanaContextMock(), children } = props; + const { store = configureStore(props.storeState), children } = props; + + const context = { + ...getGrafanaContextMock(), + ...props.grafanaContext, + }; return ( - {children} + {children} );