Explore / Query Library: Enable run button (#87882)

* Enable run button

* First pass of shared component, tests half-implemented

* cleanup
slo/gops-configuration-tracker
Kristina 1 year ago committed by GitHub
parent 3800b97a5b
commit c8d237dd56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 157
      public/app/features/explore/ExploreRunQueryButton.test.tsx
  2. 126
      public/app/features/explore/ExploreRunQueryButton.tsx
  3. 23
      public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx
  4. 10
      public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx
  5. 119
      public/app/features/explore/RichHistory/RichHistoryCard.test.tsx
  6. 105
      public/app/features/explore/RichHistory/RichHistoryCard.tsx
  7. 10
      public/locales/en-US/grafana.json
  8. 10
      public/locales/pseudo-LOCALE/grafana.json

@ -0,0 +1,157 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Provider } from 'react-redux';
import { DatasourceSrvMock, MockDataSourceApi } from 'test/mocks/datasource_srv';
import { DataSourceApi } from '@grafana/data';
import { DataQuery } from '@grafana/schema';
import { configureStore } from 'app/store/configureStore';
import { ExploreItemState, ExploreState } from 'app/types';
import { Props, ExploreRunQueryButton } from './ExploreRunQueryButton';
import { makeExplorePaneState } from './state/utils';
interface MockQuery extends DataQuery {
query: string;
queryText?: string;
}
const lokiDs = {
uid: 'loki',
name: 'testDs',
type: 'loki',
meta: { mixed: false },
getRef: () => {
return { type: 'loki', uid: 'loki' };
},
} as unknown as DataSourceApi;
const promDs = {
uid: 'prom',
name: 'testDs2',
type: 'prom',
meta: { mixed: false },
getRef: () => {
return { type: 'prom', uid: 'prom' };
},
} as unknown as DataSourceApi;
const datasourceSrv = new DatasourceSrvMock(lokiDs, {
prom: promDs,
mixed: {
uid: 'mixed',
name: 'testDSMixed',
type: 'mixed',
meta: { mixed: true },
} as MockDataSourceApi,
});
const getDataSourceSrvMock = jest.fn().mockReturnValue(datasourceSrv);
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => getDataSourceSrvMock(),
}));
const setup = (propOverrides?: Partial<Props>, paneCount = 1) => {
const props: Props = {
queries: [],
rootDatasourceUid: 'loki',
setQueries: jest.fn(),
changeDatasource: jest.fn(),
};
Object.assign(props, propOverrides);
const panes: Record<string, ExploreItemState | undefined> = {};
if (paneCount > 0) {
panes.left = makeExplorePaneState({ datasourceInstance: lokiDs });
}
if (paneCount === 2) {
panes.right = makeExplorePaneState({ datasourceInstance: lokiDs });
}
const store = configureStore({
explore: {
panes,
} as unknown as ExploreState,
});
render(
<Provider store={store}>
<ExploreRunQueryButton {...props} />
</Provider>
);
};
afterEach(() => {
jest.clearAllMocks();
});
describe('ExploreRunQueryButton', () => {
it('should disable run query button if there are no explore IDs', async () => {
setup({}, 0);
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
expect(runQueryButton).toBeDisabled();
});
it('should be disabled if the root datasource is undefined (invalid datasource)', async () => {
setup({
rootDatasourceUid: undefined,
});
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
expect(runQueryButton).toBeDisabled();
});
it('should be disabled if property is set', async () => {
setup({
disabled: true,
});
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
expect(runQueryButton).toBeDisabled();
});
it('should set new queries without changing DS when running queries from the same datasource', async () => {
const setQueries = jest.fn();
const changeDatasource = jest.fn();
const queries: MockQuery[] = [
{ query: 'query1', refId: 'A', datasource: { uid: 'loki' } },
{ query: 'query2', refId: 'B', datasource: { uid: 'loki' } },
];
setup({
setQueries,
changeDatasource,
rootDatasourceUid: 'loki',
queries,
});
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
await userEvent.click(runQueryButton);
expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries);
expect(changeDatasource).not.toHaveBeenCalled();
});
it('should change datasource to mixed and set new queries when running queries from mixed datasource', async () => {
const setQueries = jest.fn();
const changeDatasource = jest.fn();
const queries: MockQuery[] = [
{ query: 'query1', refId: 'A', datasource: { type: 'loki', uid: 'loki' } },
{ query: 'query2', refId: 'B', datasource: { type: 'prometheus', uid: 'prometheus' } },
];
setup({
setQueries,
changeDatasource,
rootDatasourceUid: 'mixed',
queries,
});
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
await userEvent.click(runQueryButton);
await waitFor(() => {
expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries);
expect(changeDatasource).toHaveBeenCalledWith({ datasource: 'mixed', exploreId: 'left' });
});
});
});

@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { ConnectedProps, connect } from 'react-redux';
import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { Button, Dropdown, Menu, ToolbarButton } from '@grafana/ui';
import { t } from '@grafana/ui/src/utils/i18n';
import { useSelector } from 'app/types';
import { changeDatasource } from './state/datasource';
import { setQueries } from './state/query';
import { isSplit, selectExploreDSMaps, selectPanesEntries } from './state/selectors';
const mapDispatchToProps = {
setQueries,
changeDatasource,
};
const connector = connect(undefined, mapDispatchToProps);
interface ExploreRunQueryButtonProps {
queries: DataQuery[];
rootDatasourceUid?: string;
disabled?: boolean;
}
export type Props = ConnectedProps<typeof connector> & ExploreRunQueryButtonProps;
/*
This component does not validate datasources before running them. Root datasource validation should happen outside this component and can pass in an undefined if invalid
If query level validation is done and a query datasource is invalid, pass in disabled = true
*/
export function ExploreRunQueryButton({
rootDatasourceUid,
queries,
disabled = false,
changeDatasource,
setQueries,
}: Props) {
const [openRunQueryButton, setOpenRunQueryButton] = useState(false);
const isPaneSplit = useSelector(isSplit);
const exploreActiveDS = useSelector(selectExploreDSMaps);
const panesEntries = useSelector(selectPanesEntries);
const isDifferentDatasource = (uid: string, exploreId: string) =>
!exploreActiveDS.dsToExplore.find((di) => di.datasource.uid === uid)?.exploreIds.includes(exploreId);
// exploreId on where the query will be ran, and the datasource ID for the item's DS
const runQueryText = (exploreId: string, dsUid?: string) => {
// if the datasource or exploreID is undefined, it will be disabled, but give it default query button text
return dsUid !== undefined && exploreId !== undefined && isDifferentDatasource(dsUid, exploreId)
? {
fallbackText: 'Switch data source and run query',
translation: t('explore.run-query.switch-datasource-button', 'Switch data source and run query'),
}
: {
fallbackText: 'Run query',
translation: t('explore.run-query.run-query-button', 'Run query'),
};
};
const runQuery = async (exploreId: string) => {
const differentDataSource = isDifferentDatasource(rootDatasourceUid!, exploreId);
if (differentDataSource) {
await changeDatasource({ exploreId, datasource: rootDatasourceUid! });
}
setQueries(exploreId, queries);
reportInteraction('grafana_explore_query_history_run', {
queryHistoryEnabled: config.queryHistoryEnabled,
differentDataSource,
});
};
const runButton = () => {
const isInvalid = disabled || queries.length === 0 || rootDatasourceUid === undefined;
if (!isPaneSplit) {
const exploreId = exploreActiveDS.exploreToDS[0]?.exploreId; // may be undefined if explore is refreshed while the pane is up
const buttonText = runQueryText(exploreId, rootDatasourceUid);
return (
<Button
variant="secondary"
aria-label={buttonText.translation}
onClick={() => runQuery(exploreId)}
disabled={isInvalid || exploreId === undefined}
>
{buttonText.translation}
</Button>
);
} else {
const menu = (
<Menu>
{panesEntries.map((pane, i) => {
const buttonText = runQueryText(pane[0], rootDatasourceUid);
const paneLabel =
i === 0 ? t('explore.run-query.left-pane', 'Left pane') : t('explore.run-query.right-pane', 'Right pane');
return (
<Menu.Item
key={i}
ariaLabel={buttonText.fallbackText}
onClick={() => {
runQuery(pane[0]);
}}
label={`${paneLabel}: ${buttonText.translation}`}
disabled={isInvalid || pane[0] === undefined}
/>
);
})}
</Menu>
);
return (
<Dropdown onVisibleChange={(state) => setOpenRunQueryButton(state)} placement="bottom-start" overlay={menu}>
<ToolbarButton aria-label="run query options" variant="canvas" isOpen={openRunQueryButton}>
{t('explore.run-query.run-query-button', 'Run query')}
</ToolbarButton>
</Dropdown>
);
}
};
return <>{runButton()}</>;
}
export default connector(ExploreRunQueryButton);

@ -1,13 +1,16 @@
import React from 'react';
import { Button } from '@grafana/ui';
export function ActionsCell() {
return (
<>
<Button disabled={true} variant="primary">
Run
</Button>
</>
);
import { DataQuery } from '@grafana/schema';
import ExploreRunQueryButton from '../../ExploreRunQueryButton';
interface ActionsCellProps {
query?: DataQuery;
rootDatasourceUid?: string;
}
function ActionsCell({ query, rootDatasourceUid }: ActionsCellProps) {
return <ExploreRunQueryButton queries={query ? [query] : []} rootDatasourceUid={rootDatasourceUid} />;
}
export default ActionsCell;

@ -4,7 +4,7 @@ import { SortByFn } from 'react-table';
import { Column, InteractiveTable } from '@grafana/ui';
import { ActionsCell } from './ActionsCell';
import ActionsCell from './ActionsCell';
import { AddedByCell } from './AddedByCell';
import { DatasourceTypeCell } from './DatasourceTypeCell';
import { DateAddedCell } from './DateAddedCell';
@ -22,7 +22,13 @@ const columns: Array<Column<QueryTemplateRow>> = [
{ id: 'addedBy', header: 'Added by', cell: AddedByCell },
{ id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' },
{ id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort },
{ id: 'actions', header: '', cell: ActionsCell },
{
id: 'actions',
header: '',
cell: ({ row: { original } }) => (
<ActionsCell query={original.query} rootDatasourceUid={original.datasourceRef?.uid} />
),
},
];
const styles = {

@ -1,4 +1,4 @@
import { fireEvent, render, screen, getByText, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, getByText } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
@ -213,12 +213,6 @@ describe('RichHistoryCard', () => {
expect(datasourceName).toHaveTextContent('Data source does not exist anymore');
});
it('should disable run query button if there are no explore IDs', async () => {
setup({}, true);
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
expect(runQueryButton).toBeDisabled();
});
describe('copy queries to clipboard', () => {
it('should copy query model to clipboard when copying a query from a non existent datasource', async () => {
setup({
@ -304,117 +298,6 @@ describe('RichHistoryCard', () => {
});
});
describe('run queries', () => {
it('should be disabled if at least one query datasource is missing when using mixed', async () => {
const setQueries = jest.fn();
const changeDatasource = jest.fn();
const queries: MockQuery[] = [
{ query: 'query1', refId: 'A', datasource: { uid: 'nonexistent-ds' } },
{ query: 'query2', refId: 'B', datasource: { uid: 'loki' } },
];
setup({
setQueries,
changeDatasource,
queryHistoryItem: {
id: '2',
createdAt: 1,
datasourceUid: 'mixed',
datasourceName: 'Mixed',
starred: false,
comment: '',
queries,
},
});
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
expect(runQueryButton).toBeDisabled();
});
it('should be disabled if at datasource is missing', async () => {
const setQueries = jest.fn();
const changeDatasource = jest.fn();
const queries: MockQuery[] = [
{ query: 'query1', refId: 'A' },
{ query: 'query2', refId: 'B' },
];
setup({
setQueries,
changeDatasource,
queryHistoryItem: {
id: '2',
createdAt: 1,
datasourceUid: 'nonexistent-ds',
datasourceName: 'nonexistent-ds',
starred: false,
comment: '',
queries,
},
});
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
expect(runQueryButton).toBeDisabled();
});
it('should only set new queries when running queries from the same datasource', async () => {
const setQueries = jest.fn();
const changeDatasource = jest.fn();
const queries: MockQuery[] = [
{ query: 'query1', refId: 'A' },
{ query: 'query2', refId: 'B' },
];
setup({
setQueries,
changeDatasource,
queryHistoryItem: {
id: '2',
createdAt: 1,
datasourceUid: 'loki',
datasourceName: 'Loki',
starred: false,
comment: '',
queries,
},
});
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
await userEvent.click(runQueryButton);
expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries);
expect(changeDatasource).not.toHaveBeenCalled();
});
it('should change datasource to mixed and set new queries when running queries from mixed datasource', async () => {
const setQueries = jest.fn();
const changeDatasource = jest.fn();
const queries: MockQuery[] = [
{ query: 'query1', refId: 'A', datasource: { type: 'loki', uid: 'loki' } },
{ query: 'query2', refId: 'B', datasource: { type: 'prometheus', uid: 'prometheus' } },
];
setup({
setQueries,
changeDatasource,
queryHistoryItem: {
id: '2',
createdAt: 1,
datasourceUid: 'mixed',
datasourceName: 'Mixed',
starred: false,
comment: '',
queries,
},
datasourceInstances: [dsStore.loki, dsStore.prometheus, dsStore.mixed],
});
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
await userEvent.click(runQueryButton);
await waitFor(() => {
expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries);
expect(changeDatasource).toHaveBeenCalledWith({ datasource: 'mixed', exploreId: 'left' });
});
});
});
describe('commenting', () => {
it('should render comment, if comment present', async () => {
setup({ queryHistoryItem: starredQueryWithComment });

@ -5,7 +5,7 @@ import { connect, ConnectedProps } from 'react-redux';
import { GrafanaTheme2, DataSourceApi } from '@grafana/data';
import { config, reportInteraction, getAppEvents } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { TextArea, Button, IconButton, useStyles2, ToolbarButton, Dropdown, Menu } from '@grafana/ui';
import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification';
import { Trans, t } from 'app/core/internationalization';
@ -16,11 +16,10 @@ import { changeDatasource } from 'app/features/explore/state/datasource';
import { starHistoryItem, commentHistoryItem, deleteHistoryItem } from 'app/features/explore/state/history';
import { setQueries } from 'app/features/explore/state/query';
import { dispatch } from 'app/store/store';
import { useSelector } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { RichHistoryQuery } from 'app/types/explore';
import { isSplit, selectExploreDSMaps, selectPanesEntries } from '../state/selectors';
import ExploreRunQueryButton from '../ExploreRunQueryButton';
const mapDispatchToProps = {
changeDatasource,
@ -134,46 +133,16 @@ const getStyles = (theme: GrafanaTheme2) => {
};
export function RichHistoryCard(props: Props) {
const {
queryHistoryItem,
commentHistoryItem,
starHistoryItem,
deleteHistoryItem,
changeDatasource,
setQueries,
datasourceInstances,
} = props;
const { queryHistoryItem, commentHistoryItem, starHistoryItem, deleteHistoryItem, datasourceInstances } = props;
const [activeUpdateComment, setActiveUpdateComment] = useState(false);
const [openRunQueryButton, setOpenRunQueryButton] = useState(false);
const [comment, setComment] = useState<string | undefined>(queryHistoryItem.comment);
const panesEntries = useSelector(selectPanesEntries);
const exploreActiveDS = useSelector(selectExploreDSMaps);
const isPaneSplit = useSelector(isSplit);
const styles = useStyles2(getStyles);
const cardRootDatasource = datasourceInstances
? datasourceInstances.find((di) => di.uid === queryHistoryItem.datasourceUid)
: undefined;
const isDifferentDatasource = (uid: string, exploreId: string) =>
!exploreActiveDS.dsToExplore.find((di) => di.datasource.uid === uid)?.exploreIds.includes(exploreId);
const onRunQuery = async (exploreId: string) => {
const queriesToRun = queryHistoryItem.queries;
const differentDataSource = isDifferentDatasource(queryHistoryItem.datasourceUid, exploreId);
if (differentDataSource) {
await changeDatasource({ exploreId, datasource: queryHistoryItem.datasourceUid });
}
setQueries(exploreId, queriesToRun);
reportInteraction('grafana_explore_query_history_run', {
queryHistoryEnabled: config.queryHistoryEnabled,
differentDataSource,
});
};
const onCopyQuery = async () => {
const datasources = [...queryHistoryItem.queries.map((query) => query.datasource?.type || 'unknown')];
reportInteraction('grafana_explore_query_history_copy_query', {
@ -344,68 +313,6 @@ export function RichHistoryCard(props: Props) {
</div>
);
// exploreId on where the query will be ran, and the datasource ID for the item's DS
const runQueryText = (exploreId: string, dsUid: string) => {
return dsUid !== undefined && exploreId !== undefined && isDifferentDatasource(dsUid, exploreId)
? {
fallbackText: 'Switch data source and run query',
translation: t('explore.rich-history-card.switch-datasource-button', 'Switch data source and run query'),
}
: {
fallbackText: 'Run query',
translation: t('explore.rich-history-card.run-query-button', 'Run query'),
};
};
const runButton = () => {
const disabled = cardRootDatasource?.uid === undefined;
if (!isPaneSplit) {
const exploreId = exploreActiveDS.exploreToDS[0]?.exploreId; // may be undefined if explore is refreshed while the pane is up
const buttonText = runQueryText(exploreId, props.queryHistoryItem.datasourceUid);
return (
<Button
variant="secondary"
aria-label={buttonText.translation}
onClick={() => onRunQuery(exploreId)}
disabled={disabled || exploreId === undefined}
>
{buttonText.translation}
</Button>
);
} else {
const menu = (
<Menu>
{panesEntries.map((pane, i) => {
const buttonText = runQueryText(pane[0], props.queryHistoryItem.datasourceUid);
const paneLabel =
i === 0
? t('explore.rich-history-card.left-pane', 'Left pane')
: t('explore.rich-history-card.right-pane', 'Right pane');
return (
<Menu.Item
key={i}
ariaLabel={buttonText.fallbackText}
onClick={() => {
onRunQuery(pane[0]);
}}
label={`${paneLabel}: ${buttonText.translation}`}
disabled={disabled}
/>
);
})}
</Menu>
);
return (
<Dropdown onVisibleChange={(state) => setOpenRunQueryButton(state)} placement="bottom-start" overlay={menu}>
<ToolbarButton aria-label="run query options" variant="canvas" isOpen={openRunQueryButton}>
{t('explore.rich-history-card.run-query-button', 'Run query')}
</ToolbarButton>
</Dropdown>
);
}
};
return (
<div className={styles.queryCard}>
<div className={styles.cardRow}>
@ -435,7 +342,11 @@ export function RichHistoryCard(props: Props) {
)}
{activeUpdateComment && updateComment}
</div>
{!activeUpdateComment && <div className={styles.runButton}>{runButton()}</div>}
{!activeUpdateComment && (
<div className={styles.runButton}>
<ExploreRunQueryButton queries={queryHistoryItem.queries} rootDatasourceUid={cardRootDatasource?.uid} />
</div>
)}
</div>
</div>
);

@ -491,15 +491,11 @@
"delete-query-tooltip": "Delete query",
"delete-starred-query-confirmation-text": "Are you sure you want to permanently delete your starred query?",
"edit-comment-tooltip": "Edit comment",
"left-pane": "Left pane",
"optional-description": "An optional description of what the query does.",
"query-comment-label": "Query comment",
"query-text-label": "Query text",
"right-pane": "Right pane",
"run-query-button": "Run query",
"save-comment": "Save comment",
"star-query-tooltip": "Star query",
"switch-datasource-button": "Switch data source and run query",
"unstar-query-tooltip": "Unstar query",
"update-comment-form": "Update comment form"
},
@ -568,6 +564,12 @@
"saving-failed": "Saving rich history failed",
"update-failed": "Rich History update failed"
},
"run-query": {
"left-pane": "Left pane",
"right-pane": "Right pane",
"run-query-button": "Run query",
"switch-datasource-button": "Switch data source and run query"
},
"secondary-actions": {
"query-add-button": "Add query",
"query-add-button-aria-label": "Add query",

@ -491,15 +491,11 @@
"delete-query-tooltip": "Đęľęŧę qūęřy",
"delete-starred-query-confirmation-text": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő pęřmäʼnęʼnŧľy đęľęŧę yőūř şŧäřřęđ qūęřy?",
"edit-comment-tooltip": "Ēđįŧ čőmmęʼnŧ",
"left-pane": "Ŀęƒŧ päʼnę",
"optional-description": "Åʼn őpŧįőʼnäľ đęşčřįpŧįőʼn őƒ ŵĥäŧ ŧĥę qūęřy đőęş.",
"query-comment-label": "Qūęřy čőmmęʼnŧ",
"query-text-label": "Qūęřy ŧęχŧ",
"right-pane": "Ŗįģĥŧ päʼnę",
"run-query-button": "Ŗūʼn qūęřy",
"save-comment": "Ŝävę čőmmęʼnŧ",
"star-query-tooltip": "Ŝŧäř qūęřy",
"switch-datasource-button": "Ŝŵįŧčĥ đäŧä şőūřčę äʼnđ řūʼn qūęřy",
"unstar-query-tooltip": "Ůʼnşŧäř qūęřy",
"update-comment-form": "Ůpđäŧę čőmmęʼnŧ ƒőřm"
},
@ -568,6 +564,12 @@
"saving-failed": "Ŝävįʼnģ řįčĥ ĥįşŧőřy ƒäįľęđ",
"update-failed": "Ŗįčĥ Ħįşŧőřy ūpđäŧę ƒäįľęđ"
},
"run-query": {
"left-pane": "Ŀęƒŧ päʼnę",
"right-pane": "Ŗįģĥŧ päʼnę",
"run-query-button": "Ŗūʼn qūęřy",
"switch-datasource-button": "Ŝŵįŧčĥ đäŧä şőūřčę äʼnđ řūʼn qūęřy"
},
"secondary-actions": {
"query-add-button": "Åđđ qūęřy",
"query-add-button-aria-label": "Åđđ qūęřy",

Loading…
Cancel
Save