diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index c212194bb57..2ceeaa18023 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -69,6 +69,7 @@ export class RefreshPicker extends PureComponent { return ( onRun(loading)} diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 7d10a6e7a86..09ff2fef6ed 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -1,32 +1,12 @@ import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import Wrapper from './Wrapper'; -import { configureStore } from '../../store/configureStore'; -import { Provider } from 'react-redux'; -import { locationService, setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; -import { - ArrayDataFrame, - DataQueryResponse, - DataSourceApi, - DataSourceInstanceSettings, - FieldType, - QueryEditorProps, - ScopedVars, - serializeStateToUrlParam, -} from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; - -import { from, Observable } from 'rxjs'; -import { LokiDatasource } from '../../plugins/datasource/loki/datasource'; -import { LokiQuery } from '../../plugins/datasource/loki/types'; -import { fromPairs } from 'lodash'; -import { EnhancedStore } from '@reduxjs/toolkit'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { locationService } from '@grafana/runtime'; +import { serializeStateToUrlParam } from '@grafana/data'; import userEvent from '@testing-library/user-event'; import { splitOpen } from './state/main'; -import { Route, Router } from 'react-router-dom'; -import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute'; -import { initialUserState } from '../profile/state/reducers'; -import { Echo } from 'app/core/services/echo/Echo'; +import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup'; +import { makeLogsQueryResponse, makeMetricsQueryResponse } from './spec/helper/query'; +import { changeDatasource } from './spec/helper/interactions'; type Mock = jest.Mock; @@ -52,17 +32,19 @@ jest.mock('react-virtualized-auto-sizer', () => { }); describe('Wrapper', () => { + afterEach(() => { + tearDown(); + }); + it('shows warning if there are no data sources', async () => { - setup({ datasources: [] }); + setupExplore({ datasources: [] }); // Will throw if isn't found screen.getByText(/Explore requires at least one data source/i); }); it('inits url and renders editor but does not call query on empty url', async () => { - const { datasources } = setup(); - - // Wait for rendering the editor - await screen.findByText(/Editor/i); + const { datasources } = setupExplore(); + await waitForExplore(); // At this point url should be initialised to some defaults expect(locationService.getSearchObject()).toEqual({ @@ -77,14 +59,14 @@ describe('Wrapper', () => { }); it('runs query when url contains query and renders results', async () => { - const query = { + const urlParams = { left: serializeStateToUrlParam({ datasource: 'loki', queries: [{ refId: 'A', expr: '{ label="value"}' }], range: { from: 'now-1h', to: 'now' }, }), }; - const { datasources, store } = setup({ query }); + const { datasources, store } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Make sure we render the logs panel @@ -99,7 +81,7 @@ describe('Wrapper', () => { // We did not change the url expect(locationService.getSearchObject()).toEqual({ orgId: '1', - ...query, + ...urlParams, }); expect(store.getState().explore.richHistory[0]).toMatchObject({ @@ -115,8 +97,8 @@ describe('Wrapper', () => { }); it('handles url change and runs the new query', async () => { - const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; - const { datasources } = setup({ query }); + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; + const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Wait for rendering the logs await screen.findByText(/custom log line/i); @@ -134,8 +116,8 @@ describe('Wrapper', () => { }); it('handles url change and runs the new query with different datasource', async () => { - const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; - const { datasources } = setup({ query }); + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; + const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Wait for rendering the logs await screen.findByText(/custom log line/i); @@ -154,11 +136,10 @@ describe('Wrapper', () => { }); it('handles changing the datasource manually', async () => { - const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) }; - const { datasources } = setup({ query }); + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) }; + const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); - // Wait for rendering the editor - await screen.findByText(/Editor/i); + await waitForExplore(); await changeDatasource('elastic'); await screen.findByText('elastic Editor input:'); @@ -174,7 +155,7 @@ describe('Wrapper', () => { }); it('opens the split pane when split button is clicked', async () => { - setup(); + setupExplore(); // Wait for rendering the editor const splitButton = await screen.findByText(/split/i); fireEvent.click(splitButton); @@ -185,7 +166,7 @@ describe('Wrapper', () => { }); it('inits with two panes if specified in url', async () => { - const query = { + const urlParams = { left: serializeStateToUrlParam({ datasource: 'loki', queries: [{ refId: 'A', expr: '{ label="value"}' }], @@ -198,7 +179,7 @@ describe('Wrapper', () => { }), }; - const { datasources } = setup({ query }); + const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); (datasources.elastic.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); @@ -219,7 +200,7 @@ describe('Wrapper', () => { // We did not change the url expect(locationService.getSearchObject()).toEqual({ orgId: '1', - ...query, + ...urlParams, }); // We called the data source query method once @@ -235,11 +216,11 @@ describe('Wrapper', () => { }); it('can close a pane from a split', async () => { - const query = { + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { refId: 'A' }]), right: JSON.stringify(['now-1h', 'now', 'elastic', { refId: 'A' }]), }; - setup({ query }); + setupExplore({ urlParams }); const closeButtons = await screen.findAllByTitle(/Close split pane/i); userEvent.click(closeButtons[1]); @@ -250,10 +231,10 @@ describe('Wrapper', () => { }); it('handles url change to split view', async () => { - const query = { + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), }; - const { datasources } = setup({ query }); + const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse()); (datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse()); @@ -268,10 +249,10 @@ describe('Wrapper', () => { }); it('handles opening split with split open func', async () => { - const query = { + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), }; - const { datasources, store } = setup({ query }); + const { datasources, store } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse()); (datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse()); @@ -287,10 +268,10 @@ describe('Wrapper', () => { }); it('changes the document title of the explore page to include the datasource in use', async () => { - const query = { + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), }; - const { datasources } = setup({ query }); + const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).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 @@ -299,10 +280,10 @@ describe('Wrapper', () => { 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 query = { + const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), }; - const { datasources, store } = setup({ query }); + const { datasources, store } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse()); (datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse()); @@ -314,142 +295,10 @@ describe('Wrapper', () => { await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana')); }); - it('removes `from` and `to` parameters from url when first mounted', () => { - setup({ searchParams: 'from=1&to=2&orgId=1' }); + it('removes `from` and `to` parameters from url when first mounted', async () => { + setupExplore({ searchParams: 'from=1&to=2&orgId=1' }); expect(locationService.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' })); expect(locationService.getSearchObject()).toEqual(expect.objectContaining({ orgId: '1' })); }); }); - -type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi }; -type SetupOptions = { - datasources?: DatasourceSetup[]; - query?: any; - searchParams?: string; -}; - -function setup(options?: SetupOptions): { datasources: { [name: string]: DataSourceApi }; store: EnhancedStore } { - // Clear this up otherwise it persists data source selection - // TODO: probably add test for that too - window.localStorage.clear(); - - // Create this here so any mocks are recreated on setup and don't retain state - const defaultDatasources: DatasourceSetup[] = [ - makeDatasourceSetup(), - makeDatasourceSetup({ name: 'elastic', id: 2 }), - ]; - - const dsSettings = options?.datasources || defaultDatasources; - - setDataSourceSrv({ - getList(): DataSourceInstanceSettings[] { - return dsSettings.map((d) => d.settings); - }, - getInstanceSettings(name: string) { - return dsSettings.map((d) => d.settings).find((x) => x.name === name || x.uid === name); - }, - get(name?: string | null, scopedVars?: ScopedVars): Promise { - return Promise.resolve( - (name ? dsSettings.find((d) => d.api.name === name || d.api.uid === name) : dsSettings[0])!.api - ); - }, - } as any); - - setEchoSrv(new Echo()); - - const store = configureStore(); - store.getState().user = { - ...initialUserState, - orgId: 1, - timeZone: 'utc', - }; - - store.getState().navIndex = { - explore: { - id: 'explore', - text: 'Explore', - subTitle: 'Explore your data', - icon: 'compass', - url: '/explore', - }, - }; - - locationService.push({ pathname: '/explore', search: options?.searchParams }); - - if (options?.query) { - locationService.partial(options.query); - } - - const route = { component: Wrapper }; - - render( - - - } /> - - - ); - - return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store }; -} - -function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: number } = {}): DatasourceSetup { - const meta: any = { - info: { - logos: { - small: '', - }, - }, - id: id.toString(), - }; - return { - settings: { - id, - uid: name, - type: 'logs', - name, - meta, - access: 'proxy', - jsonData: {}, - }, - api: { - components: { - QueryEditor(props: QueryEditorProps) { - return ( -
- {name} Editor input: {props.query.expr} -
- ); - }, - }, - name: name, - uid: name, - query: jest.fn(), - getRef: jest.fn(), - meta, - } as any, - }; -} - -function makeLogsQueryResponse(marker = ''): Observable { - const df = new ArrayDataFrame([{ ts: Date.now(), line: `custom log line ${marker}` }]); - df.meta = { - preferredVisualisationType: 'logs', - }; - df.fields[0].type = FieldType.time; - return from([{ data: [df] }]); -} - -function makeMetricsQueryResponse(): Observable { - const df = new ArrayDataFrame([{ ts: Date.now(), val: 1 }]); - df.fields[0].type = FieldType.time; - return from([{ data: [df] }]); -} - -async function changeDatasource(name: string) { - const datasourcePicker = (await screen.findByLabelText(selectors.components.DataSourcePicker.container)).children[0]; - fireEvent.keyDown(datasourcePicker, { keyCode: 40 }); - const option = screen.getByText(name); - fireEvent.click(option); -} diff --git a/public/app/features/explore/spec/helper/assert.ts b/public/app/features/explore/spec/helper/assert.ts new file mode 100644 index 00000000000..e0ec61f94f1 --- /dev/null +++ b/public/app/features/explore/spec/helper/assert.ts @@ -0,0 +1,7 @@ +import { screen } from '@testing-library/react'; + +export const assertQueryHistoryExists = (query: string) => { + expect(screen.getByText('1 queries')).toBeInTheDocument(); + const queryItem = screen.getByLabelText('Query text'); + expect(queryItem).toHaveTextContent(query); +}; diff --git a/public/app/features/explore/spec/helper/interactions.ts b/public/app/features/explore/spec/helper/interactions.ts new file mode 100644 index 00000000000..50675cac69c --- /dev/null +++ b/public/app/features/explore/spec/helper/interactions.ts @@ -0,0 +1,28 @@ +import { selectors } from '@grafana/e2e-selectors'; +import userEvent from '@testing-library/user-event'; +import { fireEvent, screen } from '@testing-library/react'; + +export const changeDatasource = async (name: string) => { + const datasourcePicker = (await screen.findByLabelText(selectors.components.DataSourcePicker.container)).children[0]; + fireEvent.keyDown(datasourcePicker, { keyCode: 40 }); + const option = screen.getByText(name); + fireEvent.click(option); +}; + +export const inputQuery = (query: string) => { + const input = screen.getByRole('textbox', { name: 'query' }); + userEvent.type(input, query); +}; + +export const runQuery = () => { + const button = screen.getByRole('button', { name: /run query/i }); + userEvent.click(button); +}; + +export const openQueryHistory = async () => { + const button = screen.getByRole('button', { name: 'Rich history button' }); + userEvent.click(button); + expect( + await screen.findByText('The history is local to your browser and is not shared with others.') + ).toBeInTheDocument(); +}; diff --git a/public/app/features/explore/spec/helper/query.ts b/public/app/features/explore/spec/helper/query.ts new file mode 100644 index 00000000000..b1156fc6673 --- /dev/null +++ b/public/app/features/explore/spec/helper/query.ts @@ -0,0 +1,17 @@ +import { from, Observable } from 'rxjs'; +import { ArrayDataFrame, DataQueryResponse, FieldType } from '@grafana/data'; + +export function makeLogsQueryResponse(marker = ''): Observable { + const df = new ArrayDataFrame([{ ts: Date.now(), line: `custom log line ${marker}` }]); + df.meta = { + preferredVisualisationType: 'logs', + }; + df.fields[0].type = FieldType.time; + return from([{ data: [df] }]); +} + +export function makeMetricsQueryResponse(): Observable { + const df = new ArrayDataFrame([{ ts: Date.now(), val: 1 }]); + df.fields[0].type = FieldType.time; + return from([{ data: [df] }]); +} diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx new file mode 100644 index 00000000000..1bddee54436 --- /dev/null +++ b/public/app/features/explore/spec/helper/setup.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { EnhancedStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import { Route, Router } from 'react-router-dom'; +import { fromPairs } from 'lodash'; + +import { DataSourceApi, DataSourceInstanceSettings, QueryEditorProps, ScopedVars } from '@grafana/data'; +import { locationService, setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; +import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute'; +import { Echo } from 'app/core/services/echo/Echo'; +import { configureStore } from 'app/store/configureStore'; + +import Wrapper from '../../Wrapper'; +import { initialUserState } from '../../../profile/state/reducers'; + +import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource'; +import { LokiQuery } from '../../../../plugins/datasource/loki/types'; + +type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi }; + +type SetupOptions = { + // default true + clearLocalStorage?: boolean; + datasources?: DatasourceSetup[]; + urlParams?: { left: string; right?: string }; + searchParams?: string; +}; + +export function setupExplore(options?: SetupOptions): { + datasources: { [name: string]: DataSourceApi }; + store: EnhancedStore; + unmount: () => void; +} { + // Clear this up otherwise it persists data source selection + // TODO: probably add test for that too + if (options?.clearLocalStorage !== false) { + window.localStorage.clear(); + } + + // Create this here so any mocks are recreated on setup and don't retain state + const defaultDatasources: DatasourceSetup[] = [ + makeDatasourceSetup(), + makeDatasourceSetup({ name: 'elastic', id: 2 }), + ]; + + const dsSettings = options?.datasources || defaultDatasources; + + setDataSourceSrv({ + getList(): DataSourceInstanceSettings[] { + return dsSettings.map((d) => d.settings); + }, + getInstanceSettings(name: string) { + return dsSettings.map((d) => d.settings).find((x) => x.name === name || x.uid === name); + }, + get(name?: string | null, scopedVars?: ScopedVars): Promise { + return Promise.resolve( + (name ? dsSettings.find((d) => d.api.name === name || d.api.uid === name) : dsSettings[0])!.api + ); + }, + } as any); + + setEchoSrv(new Echo()); + + const store = configureStore(); + store.getState().user = { + ...initialUserState, + orgId: 1, + timeZone: 'utc', + }; + + store.getState().navIndex = { + explore: { + id: 'explore', + text: 'Explore', + subTitle: 'Explore your data', + icon: 'compass', + url: '/explore', + }, + }; + + locationService.push({ pathname: '/explore', search: options?.searchParams }); + + if (options?.urlParams) { + locationService.partial(options.urlParams); + } + + const route = { component: Wrapper }; + + const { unmount } = render( + + + } /> + + + ); + + return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store, unmount }; +} + +function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: number } = {}): DatasourceSetup { + const meta: any = { + info: { + logos: { + small: '', + }, + }, + id: id.toString(), + }; + return { + settings: { + id, + uid: name, + type: 'logs', + name, + meta, + access: 'proxy', + jsonData: {}, + }, + api: { + components: { + QueryEditor(props: QueryEditorProps) { + return ( +
+ { + props.onChange({ ...props.query, expr: event.target.value }); + }} + /> + {name} Editor input: {props.query.expr} +
+ ); + }, + }, + name: name, + uid: name, + query: jest.fn(), + getRef: jest.fn(), + meta, + } as any, + }; +} + +export const waitForExplore = async () => { + await screen.findByText(/Editor/i); +}; + +export const tearDown = () => { + window.localStorage.clear(); +}; diff --git a/public/app/features/explore/spec/queryHistory.test.tsx b/public/app/features/explore/spec/queryHistory.test.tsx new file mode 100644 index 00000000000..2451de00a3a --- /dev/null +++ b/public/app/features/explore/spec/queryHistory.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { setupExplore, tearDown, waitForExplore } from './helper/setup'; +import { inputQuery, openQueryHistory, runQuery } from './helper/interactions'; +import { assertQueryHistoryExists } from './helper/assert'; +import { makeLogsQueryResponse } from './helper/query'; + +jest.mock('react-virtualized-auto-sizer', () => { + return { + __esModule: true, + default(props: any) { + return
{props.children({ width: 1000 })}
; + }, + }; +}); + +describe('Explore: Query History', () => { + const USER_INPUT = 'my query'; + const RAW_QUERY = `{"expr":"${USER_INPUT}"}`; + + afterEach(() => { + tearDown(); + }); + + it('adds new query history items after the query is run.', async () => { + // when Explore is opened + const { datasources, unmount } = setupExplore(); + (datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse()); + await waitForExplore(); + + // and a user runs a query and opens query history + inputQuery(USER_INPUT); + runQuery(); + await openQueryHistory(); + + // the query that was run is in query history + assertQueryHistoryExists(RAW_QUERY); + + // when Explore is opened again + unmount(); + setupExplore({ clearLocalStorage: false }); + await waitForExplore(); + + // previously added query is in query history + await openQueryHistory(); + assertQueryHistoryExists(RAW_QUERY); + }); +});