Query library: Refactor to use onSelectQuery callback (#100360)

* starting to refactor query library to use callback

* replace QueryActionButton with onSelectQuery

* hook up properly in explore

* fix unit tests

* i18n

* extract types

* fix refId in explore

* fix unit tests

* handle changing datasource to mixed

* enrich queries with datasource

* move out into separate function

* filter out expression datasources
This commit is contained in:
Ashley Harrison 2025-02-14 14:44:47 +00:00 committed by GitHub
parent 9cff383830
commit b9034f413e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 188 additions and 213 deletions

View File

@ -13,7 +13,7 @@ import {
} from '@grafana/scenes';
import { DataQuery } from '@grafana/schema';
import { Button, Stack, Tab } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { Trans } from 'app/core/internationalization';
import { addQuery } from 'app/core/utils/query';
import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard';
import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils';
@ -25,8 +25,10 @@ import { updateQueries } from 'app/features/query/state/updateQueries';
import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard/runSharedRequest';
import { QueryGroupOptions } from 'app/types';
import { MIXED_DATASOURCE_NAME } from '../../../../plugins/datasource/mixed/MixedDataSource';
import { useQueryLibraryContext } from '../../../explore/QueryLibrary/QueryLibraryContext';
import { QueryActionButtonProps } from '../../../explore/QueryLibrary/types';
import { ExpressionDatasourceUID } from '../../../expressions/types';
import { getDatasourceSrv } from '../../../plugins/datasource_srv';
import { PanelTimeRange } from '../../scene/PanelTimeRange';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../../utils/utils';
import { getUpdatedHoverHeader } from '../getPanelFrameOptions';
@ -315,13 +317,35 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
return null;
}
const showAddButton = !isSharedDashboardQuery(dsSettings.name);
// Make the final query library action button by injecting actual addQuery functionality into the button.
const addQueryActionButton = makeQueryActionButton((queries) => {
for (const query of queries) {
model.onQueriesChange(addQuery(model.getQueries(), query));
const onSelectQueryFromLibrary = async (query: DataQuery) => {
// ensure all queries explicitly define a datasource
const enrichedQueries = queries.map((q) =>
q.datasource
? q
: {
...q,
datasource: datasource.getRef(),
}
);
const newQueries = addQuery(enrichedQueries, query);
model.onQueriesChange(newQueries);
if (query.datasource?.uid) {
const uniqueDatasources = new Set(
newQueries.map((q) => q.datasource?.uid).filter((uid) => uid !== ExpressionDatasourceUID)
);
const isMixed = uniqueDatasources.size > 1;
const newDatasourceRef = {
uid: isMixed ? MIXED_DATASOURCE_NAME : query.datasource.uid,
};
const shouldChangeDatasource = datasource.uid !== newDatasourceRef.uid;
if (shouldChangeDatasource) {
const newDatasource = getDatasourceSrv().getInstanceSettings(newDatasourceRef);
if (newDatasource) {
await model.onChangeDataSource(newDatasource);
}
}
}
});
};
return (
<div data-testid={selectors.components.QueryTab.content}>
@ -358,9 +382,9 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
{queryLibraryEnabled && (
<Button
icon="plus"
onClick={() => {
openQueryLibraryDrawer(getDatasourceNames(datasource, queries), addQueryActionButton);
}}
onClick={() =>
openQueryLibraryDrawer(getDatasourceNames(datasource, queries), onSelectQueryFromLibrary)
}
variant="secondary"
data-testid={selectors.components.QueryTab.addQuery}
>
@ -385,28 +409,6 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
);
}
/**
* Creates a button component that will be used in query library as action next to each query.
* @param addQueries
*/
function makeQueryActionButton(addQueries: (queries: DataQuery[]) => void) {
return function AddQueryFromLibraryButton(props: QueryActionButtonProps) {
const label = t('dashboards.query-library.add-query-button', 'Add query');
return (
<Button
variant={'primary'}
aria-label={label}
onClick={() => {
addQueries(props.queries);
props.onClick();
}}
>
{label}
</Button>
);
};
}
function getDatasourceNames(datasource: DataSourceApi, queries: DataQuery[]): string[] {
if (datasource.uid === '-- Mixed --') {
// If datasource is mixed, the datasource UID is on the query. Here we map the UIDs to datasource names.

View File

@ -103,6 +103,11 @@ const dummyProps: Props = {
setSupplementaryQueryEnabled: jest.fn(),
correlationEditorDetails: undefined,
correlationEditorHelperData: undefined,
exploreActiveDS: {
exploreToDS: [],
dsToExplore: [],
},
changeDatasource: jest.fn(),
};
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {

View File

@ -8,6 +8,7 @@ import {
AbsoluteTimeRange,
DataFrame,
EventBus,
getNextRefId,
GrafanaTheme2,
hasToggleableQueryFiltersSupport,
LoadingState,
@ -54,6 +55,7 @@ import { ResponseErrorContainer } from './ResponseErrorContainer';
import { SecondaryActions } from './SecondaryActions';
import TableContainer from './Table/TableContainer';
import { TraceViewContainer } from './TraceView/TraceViewContainer';
import { changeDatasource } from './state/datasource';
import { changeSize } from './state/explorePane';
import { splitOpen } from './state/main';
import {
@ -65,7 +67,7 @@ import {
setQueries,
setSupplementaryQueryEnabled,
} from './state/query';
import { isSplit } from './state/selectors';
import { isSplit, selectExploreDSMaps } from './state/selectors';
import { updateTimeRange } from './state/time';
const getStyles = (theme: GrafanaTheme2) => {
@ -605,6 +607,28 @@ export class Explore extends PureComponent<Props, ExploreState> {
queryInspectorButtonActive={showQueryInspector}
onClickAddQueryRowButton={this.onClickAddQueryRowButton}
onClickQueryInspectorButton={() => setShowQueryInspector(!showQueryInspector)}
onSelectQueryFromLibrary={async (query) => {
const { changeDatasource, queries, setQueries } = this.props;
const newQueries = [
...queries,
{
...query,
refId: getNextRefId(queries),
},
];
setQueries(exploreId, newQueries);
if (query.datasource?.uid) {
const uniqueDatasources = new Set(newQueries.map((q) => q.datasource?.uid));
const isMixed = uniqueDatasources.size > 1;
const newDatasourceRef = {
uid: isMixed ? MIXED_DATASOURCE_NAME : query.datasource.uid,
};
const shouldChangeDatasource = datasourceInstance.uid !== newDatasourceRef.uid;
if (shouldChangeDatasource) {
await changeDatasource({ exploreId, datasource: newDatasourceRef });
}
}
}}
/>
<ResponseErrorContainer exploreId={exploreId} />
</PanelContainer>
@ -716,10 +740,12 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showLogsSample,
correlationEditorHelperData,
correlationEditorDetails: explore.correlationEditorDetails,
exploreActiveDS: selectExploreDSMaps(state),
};
}
const mapDispatchToProps = {
changeDatasource,
changeSize,
modifyQueries,
scanStart,

View File

@ -28,8 +28,6 @@ import { getFiscalYearStartMonth, getTimeZone } from '../profile/state/selectors
import { ExploreTimeControls } from './ExploreTimeControls';
import { LiveTailButton } from './LiveTailButton';
import { useQueriesDrawerContext } from './QueriesDrawer/QueriesDrawerContext';
import { QueriesDrawerDropdown } from './QueriesDrawer/QueriesDrawerDropdown';
import { useQueryLibraryContext } from './QueryLibrary/QueryLibraryContext';
import { ShortLinkButtonMenu } from './ShortLinkButtonMenu';
import { ToolbarExtensionPoint } from './extensions/ToolbarExtensionPoint';
import { changeDatasource } from './state/datasource';
@ -93,7 +91,6 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
const isCorrelationsEditorMode = correlationDetails?.editorMode || false;
const isLeftPane = useSelector(isLeftPaneSelector(exploreId));
const { drawerOpened, setDrawerOpened } = useQueriesDrawerContext();
const { queryLibraryEnabled } = useQueryLibraryContext();
const shouldRotateSplitIcon = useMemo(
() => (isLeftPane && isLargerPane) || (!isLeftPane && !isLargerPane),
@ -206,23 +203,18 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
dispatch(changeRefreshInterval({ exploreId, refreshInterval }));
};
const navBarActions = [<ShortLinkButtonMenu key="share" />];
if (queryLibraryEnabled) {
navBarActions.unshift(<QueriesDrawerDropdown key="queryLibrary" variant="full" />);
} else {
navBarActions.unshift(
<ToolbarButton
variant={drawerOpened ? 'active' : 'canvas'}
aria-label={t('explore.secondary-actions.query-history-button-aria-label', 'Query history')}
onClick={() => setDrawerOpened(!drawerOpened)}
data-testid={Components.QueryTab.queryHistoryButton}
icon="history"
>
<Trans i18nKey="explore.secondary-actions.query-history-button">Query history</Trans>
</ToolbarButton>
);
}
const navBarActions = [
<ToolbarButton
variant={drawerOpened ? 'active' : 'canvas'}
aria-label={t('explore.secondary-actions.query-history-button-aria-label', 'Query history')}
onClick={() => setDrawerOpened(!drawerOpened)}
data-testid={Components.QueryTab.queryHistoryButton}
icon="history"
>
<Trans i18nKey="explore.secondary-actions.query-history-button">Query history</Trans>
</ToolbarButton>,
<ShortLinkButtonMenu key="share" />,
];
return (
<div>

View File

@ -1,120 +0,0 @@
import { css } from '@emotion/css';
import { ComponentProps, useState } from 'react';
import { Button, ButtonGroup, Dropdown, Menu, ToolbarButton } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui/';
import { t } from 'app/core/internationalization';
import { createDatasourcesList } from '../../../core/utils/richHistory';
import { useSelector } from '../../../types';
import ExploreRunQueryButton from '../ExploreRunQueryButton';
import { useQueryLibraryContext } from '../QueryLibrary/QueryLibraryContext';
import { QueryActionButton } from '../QueryLibrary/types';
import { selectExploreDSMaps } from '../state/selectors';
import { useQueriesDrawerContext } from './QueriesDrawerContext';
import { i18n } from './utils';
// This makes TS happy as ExploreRunQueryButton has optional onClick prop while QueryActionButton doesn't
// in addition to map the rootDatasourceUid prop.
function ExploreRunQueryButtonWrapper(props: ComponentProps<QueryActionButton>) {
return <ExploreRunQueryButton {...props} rootDatasourceUid={props.datasourceUid} />;
}
type Props = {
variant: 'compact' | 'full';
};
/**
* Dropdown button that can either open a Query History drawer or a Query Library drawer.
* @param variant
* @constructor
*/
export function QueriesDrawerDropdown({ variant }: Props) {
const { drawerOpened, setDrawerOpened } = useQueriesDrawerContext();
const {
openDrawer: openQueryLibraryDrawer,
closeDrawer: closeQueryLibraryDrawer,
isDrawerOpen: isQueryLibraryDrawerOpen,
queryLibraryEnabled,
} = useQueryLibraryContext();
const [queryOption, setQueryOption] = useState<'library' | 'history'>('library');
const exploreActiveDS = useSelector(selectExploreDSMaps);
const styles = useStyles2(getStyles);
// In case query library is not enabled we show only simple button for query history in the parent.
if (!queryLibraryEnabled) {
return undefined;
}
function toggleRichHistory() {
setQueryOption('history');
setDrawerOpened(!drawerOpened);
}
function toggleQueryLibrary() {
setQueryOption('library');
if (isQueryLibraryDrawerOpen) {
closeQueryLibraryDrawer();
} else {
// Prefill the query library filter with the dataSource.
// Get current dataSource that is open. As this is only used in Explore we get it from Explore state.
const listOfDatasources = createDatasourcesList();
const activeDatasources = exploreActiveDS.dsToExplore
.map((eDs) => {
return listOfDatasources.find((ds) => ds.uid === eDs.datasource?.uid)?.name;
})
.filter((name): name is string => !!name);
openQueryLibraryDrawer(activeDatasources, ExploreRunQueryButtonWrapper);
}
}
const menu = (
<Menu>
<Menu.Item label={i18n.queryLibrary} onClick={() => toggleQueryLibrary()} />
<Menu.Item label={i18n.queryHistory} onClick={() => toggleRichHistory()} />
</Menu>
);
const buttonLabel = queryOption === 'library' ? i18n.queryLibrary : i18n.queryHistory;
const toggle = queryOption === 'library' ? toggleQueryLibrary : toggleRichHistory;
return (
<ButtonGroup>
<ToolbarButton
icon="book"
variant={drawerOpened || isQueryLibraryDrawerOpen ? 'active' : 'canvas'}
onClick={() => toggle()}
aria-label={buttonLabel}
>
{variant === 'full' ? buttonLabel : undefined}
</ToolbarButton>
{/* Show either a drops down button so that user can select QL or QH, or show a close button if one of them is
already open.*/}
{drawerOpened || isQueryLibraryDrawerOpen ? (
<Button className={styles.close} variant="secondary" icon="times" onClick={() => toggle()}></Button>
) : (
<Dropdown overlay={menu}>
<ToolbarButton
className={styles.toggle}
variant="canvas"
icon="angle-down"
aria-label={t('explore.rich-history.library-history-dropdown', 'Open query library or query history')}
/>
</Dropdown>
)}
</ButtonGroup>
);
}
const getStyles = () => ({
toggle: css({ width: '36px' }),
// tweaking icon position so it's nicely aligned when dropdown turns into a close button
close: css({ width: '36px', '> svg': { position: 'relative', left: 2 } }),
});

View File

@ -2,7 +2,7 @@ import { createContext, ReactNode, useContext } from 'react';
import { DataQuery } from '@grafana/schema';
import { QueryActionButton } from './types';
import { OnSelectQueryType } from './types';
/**
* Context with state and action to interact with Query Library. The Query Library feature consists of a drawer
@ -20,11 +20,7 @@ export type QueryLibraryContextType = {
* @param options.context Used for tracking. Should identify the context this is called from, like 'explore' or
* 'dashboard'.
*/
openDrawer: (
datasourceFilters: string[],
queryActionButton: QueryActionButton,
options?: { context?: string }
) => void;
openDrawer: (datasourceFilters: string[], onSelectQuery: OnSelectQueryType, options?: { context?: string }) => void;
closeDrawer: () => void;
isDrawerOpen: boolean;

View File

@ -0,0 +1,25 @@
import { PropsWithChildren } from 'react';
import { QueryLibraryContext } from './QueryLibraryContext';
type Props = {
queryLibraryAvailable?: boolean;
};
export function QueryLibraryContextProviderMock(props: PropsWithChildren<Props>) {
return (
<QueryLibraryContext.Provider
value={{
openDrawer: jest.fn(),
closeDrawer: jest.fn(),
isDrawerOpen: false,
openAddQueryModal: jest.fn(),
closeAddQueryModal: jest.fn(),
renderSaveQueryButton: jest.fn(),
queryLibraryEnabled: false,
}}
>
{props.children}
</QueryLibraryContext.Provider>
);
}

View File

@ -1,11 +1,3 @@
import { ComponentType } from 'react';
import { DataQuery } from '@grafana/schema';
export type QueryActionButtonProps = {
queries: DataQuery[];
datasourceUid?: string;
onClick: () => void;
};
export type QueryActionButton = ComponentType<QueryActionButtonProps>;
export type OnSelectQueryType = (query: DataQuery) => void;

View File

@ -1,13 +1,34 @@
import { render, screen } from '@testing-library/react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { noop } from 'lodash';
import { render } from '../../../test/test-utils';
import { QueriesDrawerContextProviderMock } from './QueriesDrawer/mocks';
import { QueryLibraryContextProviderMock } from './QueryLibrary/mocks';
import { SecondaryActions } from './SecondaryActions';
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
return {
getDataSourceSrv: () => ({
get: () => Promise.resolve({}),
getList: () => [],
getInstanceSettings: () => {},
}),
};
});
describe('SecondaryActions', () => {
it('should render component with two buttons', () => {
render(<SecondaryActions onClickAddQueryRowButton={noop} onClickQueryInspectorButton={noop} />);
render(
<QueryLibraryContextProviderMock>
<SecondaryActions
onClickAddQueryRowButton={noop}
onClickQueryInspectorButton={noop}
onSelectQueryFromLibrary={noop}
/>
</QueryLibraryContextProviderMock>
);
expect(screen.getByRole('button', { name: /Add query/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Query inspector/i })).toBeInTheDocument();
@ -21,6 +42,7 @@ describe('SecondaryActions', () => {
richHistoryRowButtonHidden={true}
onClickAddQueryRowButton={noop}
onClickQueryInspectorButton={noop}
onSelectQueryFromLibrary={noop}
/>
</QueriesDrawerContextProviderMock>
);
@ -35,6 +57,7 @@ describe('SecondaryActions', () => {
addQueryRowButtonDisabled={true}
onClickAddQueryRowButton={noop}
onClickQueryInspectorButton={noop}
onSelectQueryFromLibrary={noop}
/>
);
@ -54,6 +77,7 @@ describe('SecondaryActions', () => {
<SecondaryActions
onClickAddQueryRowButton={onClickAddRow}
onClickQueryInspectorButton={onClickQueryInspector}
onSelectQueryFromLibrary={noop}
/>
</QueriesDrawerContextProviderMock>
);

View File

@ -3,6 +3,14 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { ToolbarButton, useTheme2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { useSelector } from 'app/types';
import { createDatasourcesList } from '../../core/utils/richHistory';
import { MIXED_DATASOURCE_NAME } from '../../plugins/datasource/mixed/MixedDataSource';
import { useQueryLibraryContext } from './QueryLibrary/QueryLibraryContext';
import { type OnSelectQueryType } from './QueryLibrary/types';
import { selectExploreDSMaps } from './state/selectors';
type Props = {
addQueryRowButtonDisabled?: boolean;
@ -12,6 +20,7 @@ type Props = {
onClickAddQueryRowButton: () => void;
onClickQueryInspectorButton: () => void;
onSelectQueryFromLibrary: OnSelectQueryType;
};
const getStyles = (theme: GrafanaTheme2) => {
@ -25,27 +34,57 @@ const getStyles = (theme: GrafanaTheme2) => {
};
};
export function SecondaryActions(props: Props) {
export function SecondaryActions({
addQueryRowButtonDisabled,
addQueryRowButtonHidden,
onClickAddQueryRowButton,
onClickQueryInspectorButton,
onSelectQueryFromLibrary,
queryInspectorButtonActive,
}: Props) {
const theme = useTheme2();
const styles = getStyles(theme);
const exploreActiveDS = useSelector(selectExploreDSMaps);
// Prefill the query library filter with the dataSource.
// Get current dataSource that is open. As this is only used in Explore we get it from Explore state.
const listOfDatasources = createDatasourcesList();
const activeDatasources = exploreActiveDS.dsToExplore
.map((eDs) => {
return listOfDatasources.find((ds) => ds.uid === eDs.datasource?.uid)?.name;
})
.filter((name): name is string => !!name && name !== MIXED_DATASOURCE_NAME);
const { queryLibraryEnabled, openDrawer: openQueryLibraryDrawer } = useQueryLibraryContext();
return (
<div className={styles.containerMargin}>
{!props.addQueryRowButtonHidden && (
<ToolbarButton
variant="canvas"
aria-label={t('explore.secondary-actions.query-add-button-aria-label', 'Add query')}
onClick={props.onClickAddQueryRowButton}
disabled={props.addQueryRowButtonDisabled}
icon="plus"
>
<Trans i18nKey="explore.secondary-actions.query-add-button">Add query</Trans>
</ToolbarButton>
{!addQueryRowButtonHidden && (
<>
<ToolbarButton
variant="canvas"
aria-label={t('explore.secondary-actions.query-add-button-aria-label', 'Add query')}
onClick={onClickAddQueryRowButton}
disabled={addQueryRowButtonDisabled}
icon="plus"
>
<Trans i18nKey="explore.secondary-actions.query-add-button">Add query</Trans>
</ToolbarButton>
{queryLibraryEnabled && (
<ToolbarButton
aria-label={t('explore.secondary-actions.add-from-query-library', 'Add query from library')}
variant="canvas"
onClick={() => openQueryLibraryDrawer(activeDatasources, onSelectQueryFromLibrary)}
icon="plus"
>
<Trans i18nKey="explore.secondary-actions.add-from-query-library">Add query from library</Trans>
</ToolbarButton>
)}
</>
)}
<ToolbarButton
variant={props.queryInspectorButtonActive ? 'active' : 'canvas'}
variant={queryInspectorButtonActive ? 'active' : 'canvas'}
aria-label={t('explore.secondary-actions.query-inspector-button-aria-label', 'Query inspector')}
onClick={props.onClickQueryInspectorButton}
onClick={onClickQueryInspectorButton}
icon="info-circle"
>
<Trans i18nKey="explore.secondary-actions.query-inspector-button">Query inspector</Trans>

View File

@ -40,7 +40,7 @@ export const openQueryHistory = async () => {
};
export const openQueryLibrary = async () => {
const button = screen.getByRole('button', { name: 'Query library' });
const button = screen.getByRole('button', { name: 'Add query from library' });
await userEvent.click(button);
await waitFor(async () => {
screen.getByRole('tab', {

View File

@ -1287,9 +1287,6 @@
"panel-queries": {
"add-query-from-library": "Add query from library"
},
"query-library": {
"add-query-button": "Add query"
},
"settings": {
"variables": {
"dependencies": {
@ -1371,7 +1368,6 @@
"close-tooltip": "Close query history",
"datasource-a-z": "Data source A-Z",
"datasource-z-a": "Data source Z-A",
"library-history-dropdown": "Open query library or query history",
"newest-first": "Newest first",
"oldest-first": "Oldest first",
"query-history": "Query history",
@ -1475,6 +1471,7 @@
"switch-datasource-button": "Switch data source and run query"
},
"secondary-actions": {
"add-from-query-library": "Add query from library",
"query-add-button": "Add query",
"query-add-button-aria-label": "Add query",
"query-history-button": "Query history",

View File

@ -1287,9 +1287,6 @@
"panel-queries": {
"add-query-from-library": "Åđđ qūęřy ƒřőm ľįþřäřy"
},
"query-library": {
"add-query-button": "Åđđ qūęřy"
},
"settings": {
"variables": {
"dependencies": {
@ -1371,7 +1368,6 @@
"close-tooltip": "Cľőşę qūęřy ĥįşŧőřy",
"datasource-a-z": "Đäŧä şőūřčę Å-Ż",
"datasource-z-a": "Đäŧä şőūřčę Ż-Å",
"library-history-dropdown": "Øpęʼn qūęřy ľįþřäřy őř qūęřy ĥįşŧőřy",
"newest-first": "Ńęŵęşŧ ƒįřşŧ",
"oldest-first": "Øľđęşŧ ƒįřşŧ",
"query-history": "Qūęřy ĥįşŧőřy",
@ -1475,6 +1471,7 @@
"switch-datasource-button": "Ŝŵįŧčĥ đäŧä şőūřčę äʼnđ řūʼn qūęřy"
},
"secondary-actions": {
"add-from-query-library": "Åđđ qūęřy ƒřőm ľįþřäřy",
"query-add-button": "Åđđ qūęřy",
"query-add-button-aria-label": "Åđđ qūęřy",
"query-history-button": "Qūęřy ĥįşŧőřy",