diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index cb22e191a77..f2d7fcedfc1 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -93,7 +93,11 @@ export async function getExploreUrl(args: GetExploreUrlArguments): Promise q.value); const exploreState = JSON.stringify({ - [generateExploreId()]: { range: toURLRange(timeRange.raw), queries: interpolatedQueries, datasource: dsRef?.uid }, + [generateExploreId()]: { + range: toURLRange(timeRange.raw), + queries: interpolatedQueries, + datasource: dsRef?.uid, + }, }); return locationUtil.assureBaseUrl(urlUtil.renderUrl('/explore', { panes: exploreState, schemaVersion: 1 })); } diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index cb47295d59d..3c0936eb7f5 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -17,6 +17,7 @@ import { configureStore } from 'app/store/configureStore'; import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext'; import { Explore, Props } from './Explore'; +import { QueryLibraryContextProviderMock } from './QueryLibrary/mocks'; import { initialExploreState } from './state/main'; import { scanStopAction } from './state/query'; import { createEmptyQueryResponse, makeExplorePaneState } from './state/utils'; @@ -110,6 +111,7 @@ const dummyProps: Props = { changeDatasource: jest.fn(), compact: false, changeCompactMode: jest.fn(), + queryLibraryRef: undefined, }; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -240,4 +242,57 @@ describe('Explore', () => { getBoolMock.mockRestore(); }); }); + + describe('Query Library Integration', () => { + it('should enable add query buttons when queryLibraryRef is undefined', async () => { + setup({ queryLibraryRef: undefined }); + + // Wait for the Explore component to render + await screen.findByTestId(selectors.components.DataSourcePicker.container); + + const addQueryButton = screen.getByRole('button', { name: /Add query$/i }); + expect(addQueryButton).toBeEnabled(); + }); + + it('should disable add query buttons when queryLibraryRef is set (editing from library)', async () => { + setup({ queryLibraryRef: 'library-query-123' }); + + // Wait for the Explore component to render + await screen.findByTestId(selectors.components.DataSourcePicker.container); + + const addQueryButton = screen.getByRole('button', { name: /Add query$/i }); + expect(addQueryButton).toBeDisabled(); + }); + + it('should disable both add query and add from library buttons when editing from library', async () => { + const store = configureStore({ + explore: { + ...initialExploreState, + panes: { + left: makeExplorePaneState(), + }, + }, + }); + const exploreProps = { ...dummyProps, queryLibraryRef: 'library-query-123' }; + + render( + + + + + + + + ); + + // Wait for the Explore component to render + await screen.findByTestId(selectors.components.DataSourcePicker.container); + + const addQueryButton = screen.getByRole('button', { name: /Add query$/i }); + const addFromLibraryButton = screen.getByRole('button', { name: /Add query from library/i }); + + expect(addQueryButton).toBeDisabled(); + expect(addFromLibraryButton).toBeDisabled(); + }); + }); }); diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index a88d594c6d1..9261043a36f 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -580,6 +580,7 @@ export class Explore extends PureComponent { setShowQueryInspector, splitted, compact, + queryLibraryRef, } = this.props; const { contentOutlineVisible } = this.state; const styles = getStyles(theme); @@ -654,7 +655,7 @@ export class Explore extends PureComponent { ReactNode; + renderSaveQueryButton: ( + query: DataQuery, + app?: CoreApp, + queryLibraryRef?: string, + onUpdateSuccess?: () => void + ) => ReactNode; queryLibraryEnabled: boolean; context: string; }; diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 7e74a736f8b..80e068924cb 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -11,7 +11,9 @@ import { getDatasourceSrv } from '../plugins/datasource_srv'; import { QueryEditorRows } from '../query/components/QueryEditorRows'; import { ContentOutlineItem } from './ContentOutline/ContentOutlineItem'; +import { useQueryLibraryContext } from './QueryLibrary/QueryLibraryContext'; import { changeDatasource } from './state/datasource'; +import { updateQueryLibraryRefAction } from './state/explorePane'; import { changeQueries, runQueries } from './state/query'; import { getExploreItemSelector } from './state/selectors'; @@ -32,21 +34,28 @@ const makeSelectors = (exploreId: string) => { exploreItemSelector, (s: ExploreItemState | undefined) => getDatasourceSrv().getInstanceSettings(s!.datasourceInstance?.uid)! ), + getQueryLibraryRef: createSelector(exploreItemSelector, (s) => s!.queryLibraryRef), }; }; export const QueryRows = ({ exploreId, isOpen, changeCompactMode }: Props) => { const dispatch = useDispatch(); - const { getQueries, getDatasourceInstanceSettings, getQueryResponse, getHistory, getEventBridge } = useMemo( - () => makeSelectors(exploreId), - [exploreId] - ); + const { openDrawer } = useQueryLibraryContext(); + const { + getQueries, + getDatasourceInstanceSettings, + getQueryResponse, + getHistory, + getEventBridge, + getQueryLibraryRef, + } = useMemo(() => makeSelectors(exploreId), [exploreId]); const queries = useSelector(getQueries); const dsSettings = useSelector(getDatasourceInstanceSettings); const queryResponse = useSelector(getQueryResponse); const history = useSelector(getHistory); const eventBridge = useSelector(getEventBridge); + const queryLibraryRef = useSelector(getQueryLibraryRef); const onRunQueries = useCallback(() => { dispatch(runQueries({ exploreId })); @@ -89,6 +98,22 @@ export const QueryRows = ({ exploreId, isOpen, changeCompactMode }: Props) => { reportInteraction('grafana_query_row_toggle', queryStatus === undefined ? {} : { queryEnabled: queryStatus }); }; + const onCancelQueryLibraryEdit = () => { + // Store the current queryLibraryRef before clearing it + const originalQueryRef = queryLibraryRef; + + // Clear the queryLibraryRef to exit editing mode + dispatch(updateQueryLibraryRefAction({ exploreId, queryLibraryRef: undefined })); + + // Open drawer with the original query highlighted + if (originalQueryRef) { + openDrawer([], () => {}, { + context: 'explore', + highlightQuery: originalQueryRef, + }); + } + }; + const onQueryOpenChanged = () => { // Disables compact mode when query is opened. // Compact mode can also be disabled by opening Content Outline. @@ -112,6 +137,8 @@ export const QueryRows = ({ exploreId, isOpen, changeCompactMode }: Props) => { app={CoreApp.Explore} history={history} eventBus={eventBridge} + queryLibraryRef={queryLibraryRef} + onCancelQueryLibraryEdit={onCancelQueryLibraryEdit} isOpen={isOpen} queryRowWrapper={(children, refId) => ( { expect(screen.getByRole('button', { name: /Query inspector/i })).toBeInTheDocument(); }); + it('should disable both add query buttons when addQueryRowButtonDisabled=true and query library is enabled', () => { + render( + + + + ); + + expect(screen.getByRole('button', { name: /Add query$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Add query from library/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Query inspector/i })).toBeInTheDocument(); + }); + it('should map click handlers correctly', async () => { const user = userEvent.setup(); diff --git a/public/app/features/explore/SecondaryActions.tsx b/public/app/features/explore/SecondaryActions.tsx index b5b30aaae1d..a54daf81694 100644 --- a/public/app/features/explore/SecondaryActions.tsx +++ b/public/app/features/explore/SecondaryActions.tsx @@ -82,6 +82,7 @@ export function SecondaryActions({ }) } icon="plus" + disabled={addQueryRowButtonDisabled} > Add query from library diff --git a/public/app/features/explore/hooks/useStateSync/index.test.tsx b/public/app/features/explore/hooks/useStateSync/index.test.tsx index 841a220d03e..1cceaf833bb 100644 --- a/public/app/features/explore/hooks/useStateSync/index.test.tsx +++ b/public/app/features/explore/hooks/useStateSync/index.test.tsx @@ -12,6 +12,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou import { configureStore } from 'app/store/configureStore'; import { makeDatasourceSetup } from '../../spec/helper/setup'; +import { updateQueryLibraryRefAction } from '../../state/explorePane'; import { splitClose, splitOpen } from '../../state/main'; import { useStateSync } from './'; @@ -584,4 +585,34 @@ describe('useStateSync', () => { expect(store.getState().explore.panes['one']?.queries[0]).toMatchObject({ expr: 'b', refId: 'B' }); }); }); + + it('should keep queryLibraryRef in state but not in URL', async () => { + const { store, location } = setup({ + queryParams: { + panes: JSON.stringify({ + one: { + datasource: 'loki-uid', + queries: [{ expr: 'test', refId: 'A' }], + }, + }), + schemaVersion: 1, + }, + }); + + await waitFor(() => { + expect(store.getState().explore.panes['one']).toBeDefined(); + }); + + act(() => { + store.dispatch(updateQueryLibraryRefAction({ exploreId: 'one', queryLibraryRef: 'library-query-456' })); + }); + + await waitFor(() => { + expect(store.getState().explore.panes['one']?.queryLibraryRef).toBe('library-query-456'); + + const search = location.getSearchObject(); + const panes = search.panes && typeof search.panes === 'string' ? JSON.parse(search.panes) : {}; + expect(panes.one?.queryLibraryRef).toBeUndefined(); + }); + }); }); diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index 8cc977a6717..025619cede6 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -97,6 +97,13 @@ export const changeCorrelationHelperData = createAction('explore/updateQueryLibraryRef'); + /** * Initialize Explore state with state from the URL and the React component. * Call this only on components for with the Explore state has not been initialized. @@ -109,6 +116,7 @@ interface InitializeExplorePayload { datasourceInstance?: DataSourceApi; compact: boolean; eventBridge: EventBusExtended; + queryLibraryRef?: string; } const initializeExploreAction = createAction('explore/initializeExploreAction'); @@ -147,6 +155,7 @@ export interface InitializeExploreOptions { correlationHelperData?: ExploreCorrelationHelperData; position?: number; eventBridge: EventBusExtended; + queryLibraryRef?: string; compact: boolean; } @@ -170,6 +179,7 @@ export const initializeExplore = createAsyncThunk( compact, correlationHelperData, eventBridge, + queryLibraryRef, }: InitializeExploreOptions, { dispatch, getState, fulfillWithValue } ) => { @@ -192,6 +202,7 @@ export const initializeExplore = createAsyncThunk( history, compact, eventBridge, + queryLibraryRef, }) ); if (panelsState !== undefined) { @@ -262,8 +273,15 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac }; } + if (updateQueryLibraryRefAction.match(action)) { + return { + ...state, + queryLibraryRef: action.payload.queryLibraryRef, + }; + } + if (initializeExploreAction.match(action)) { - const { queries, range, datasourceInstance, history, eventBridge, compact } = action.payload; + const { queries, range, datasourceInstance, history, eventBridge, compact, queryLibraryRef } = action.payload; return { ...state, @@ -277,6 +295,7 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac queryResponse: createEmptyQueryResponse(), cache: [], correlations: [], + queryLibraryRef, compact, }; } diff --git a/public/app/features/query/components/QueryEditorRow.test.tsx b/public/app/features/query/components/QueryEditorRow.test.tsx index 3a9d33542f1..8cc33cd4529 100644 --- a/public/app/features/query/components/QueryEditorRow.test.tsx +++ b/public/app/features/query/components/QueryEditorRow.test.tsx @@ -5,15 +5,28 @@ import { DataQueryRequest, dateTime, LoadingState, PanelData, toDataFrame } from import { DataQuery } from '@grafana/schema'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; -import { DataSourceType } from '../../alerting/unified/utils/datasource'; - -import { filterPanelDataToQuery, Props, QueryEditorRow } from './QueryEditorRow'; +import { filterPanelDataToQuery, Props, QueryEditorRow, QueryLibraryEditingBadge } from './QueryEditorRow'; const mockDS = mockDataSource({ name: 'test', - type: DataSourceType.Alertmanager, + type: 'testdata', }); +// Mock the QueryLibraryContext +const mockQueryLibraryContext = { + queryLibraryEnabled: true, +}; + +jest.mock('app/features/explore/QueryLibrary/QueryLibraryContext', () => ({ + useQueryLibraryContext: () => mockQueryLibraryContext, +})); + +// Mock the internationalization function +jest.mock('@grafana/i18n', () => ({ + ...jest.requireActual('@grafana/i18n'), + t: (key: string, defaultValue: string) => defaultValue, +})); + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getDataSourceSrv: () => ({ @@ -392,3 +405,30 @@ describe('QueryEditorRow', () => { }); }); }); + +describe('QueryLibraryBadge', () => { + beforeEach(() => { + mockQueryLibraryContext.queryLibraryEnabled = true; + }); + + it('should display badge when queryLibraryEnabled is true and queryLibraryRef is provided', () => { + render(); + expect(screen.getByText('Updating query from library')).toBeInTheDocument(); + }); + + it('should not display badge when queryLibraryEnabled is false', () => { + mockQueryLibraryContext.queryLibraryEnabled = false; + render(); + expect(screen.queryByText('Updating query from library')).not.toBeInTheDocument(); + }); + + it('should not display badge when queryLibraryRef is not provided', () => { + render(); + expect(screen.queryByText('Updating query from library')).not.toBeInTheDocument(); + }); + + it('should not display badge when queryLibraryRef is empty string', () => { + render(); + expect(screen.queryByText('Updating query from library')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 4a2e1511ae2..65631c9b568 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -22,7 +22,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { getDataSourceSrv, renderLimitedComponents, reportInteraction, usePluginComponents } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { Badge, ErrorBoundaryAlert, List } from '@grafana/ui'; +import { Badge, Divider, ErrorBoundaryAlert, List } from '@grafana/ui'; import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp'; import { QueryOperationAction, @@ -68,6 +68,8 @@ export interface Props { onQueryReplacedFromLibrary?: () => void; collapsable?: boolean; hideRefId?: boolean; + queryLibraryRef?: string; + onCancelQueryLibraryEdit?: () => void; isOpen?: boolean; } @@ -233,6 +235,19 @@ export class QueryEditorRow extends PureComponent { + const { query } = this.props; + reportInteraction('query_library-update_query_from_explore_cancelled', { + datasourceType: query.datasource?.type, + }); + this.props.onCancelQueryLibraryEdit?.(); + }; + + onExitQueryLibraryEditingMode = () => { + // Exit query library editing mode after successful update + this.props.onCancelQueryLibraryEdit?.(); + }; + onCopyQuery = () => { const { query, onAddQuery, onQueryCopied } = this.props; const copy = cloneDeep(query); @@ -326,6 +341,11 @@ export class QueryEditorRow extends PureComponent { + const { queryLibraryRef } = this.props; + return ; + }; + renderExtraActions = () => { const { query, queries, data, onAddQuery, dataSource, app } = this.props; @@ -363,24 +383,46 @@ export class QueryEditorRow extends PureComponent - - { - onQueryReplacedFromLibrary?.(); - onReplace?.(query); - }} + {isEditingQueryLibrary && this.renderQueryLibraryEditingBadge()} + + {!isEditingQueryLibrary && ( + { + onQueryReplacedFromLibrary?.(); + onReplace?.(query); + }} + app={this.props.app} + /> + )} + + {isEditingQueryLibrary && ( + <> + + + + )} + {hasEditorHelp && ( extends PureComponent )} {this.renderExtraActions()} - + {!isEditingQueryLibrary && ( + + )} + {!hideHideQueryButton ? ( extends PureComponent ) : null} - + {!isEditingQueryLibrary && ( + + )} ); }; @@ -488,6 +535,30 @@ export class QueryEditorRow extends PureComponent + ); +} + /** * Get a version of the PanelData limited to the query we are looking at */ @@ -530,9 +601,14 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat } // Will render anything only if query library is enabled -function MaybeQueryLibrarySaveButton(props: { query: DataQuery; app?: CoreApp }) { +function MaybeQueryLibrarySaveButton(props: { + query: DataQuery; + app?: CoreApp; + queryLibraryRef?: string; + onUpdateSuccess?: () => void; +}) { const { renderSaveQueryButton } = useQueryLibraryContext(); - return renderSaveQueryButton(props.query, props.app); + return renderSaveQueryButton(props.query, props.app, props.queryLibraryRef, props.onUpdateSuccess); } interface ReplaceQueryFromLibraryProps { diff --git a/public/app/features/query/components/QueryEditorRows.tsx b/public/app/features/query/components/QueryEditorRows.tsx index b1cc3e70ba2..373638e3901 100644 --- a/public/app/features/query/components/QueryEditorRows.tsx +++ b/public/app/features/query/components/QueryEditorRows.tsx @@ -41,6 +41,8 @@ export interface Props { onUpdateDatasources?: (datasource: DataSourceRef) => void; onQueryReplacedFromLibrary?: () => void; queryRowWrapper?: (children: ReactNode, refId: string) => ReactNode; + queryLibraryRef?: string; + onCancelQueryLibraryEdit?: () => void; isOpen?: boolean; } @@ -178,6 +180,8 @@ export class QueryEditorRows extends PureComponent { onQueryOpenChanged, onQueryReplacedFromLibrary, queryRowWrapper, + queryLibraryRef, + onCancelQueryLibraryEdit, isOpen, } = this.props; @@ -217,6 +221,8 @@ export class QueryEditorRows extends PureComponent { range={getTimeSrv().timeRange()} history={history} eventBus={eventBus} + queryLibraryRef={queryLibraryRef} + onCancelQueryLibraryEdit={onCancelQueryLibraryEdit} isOpen={isOpen} /> ); diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 8c6912676da..5d4649500b1 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -140,6 +140,11 @@ export interface ExploreItemState { * Used to distinguish URL state injection versus split view state injection. */ initialized: boolean; + /** + * Query library reference identifier when editing a query from the query library + * + */ + queryLibraryRef?: string; /** * Log query result to be displayed in the logs result viewer. */ diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 5d551355482..c0631410c8e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11850,6 +11850,7 @@ }, "query-operation": { "header": { + "cancel-query-library-edit": "Discard changes", "collapse-row": "Collapse query row", "datasource-help": "Show data source help", "drag-and-drop": "Drag and drop to reorder", @@ -11860,7 +11861,11 @@ "replace-query-from-library": "Replace with query from library", "show-response": "Show response" }, - "query-editor-not-exported": "Data source plugin does not export any Query Editor component" + "query-editor-not-exported": "Data source plugin does not export any Query Editor component", + "query-library": { + "editing-tooltip": "Updating query from library\nUID: {{queryLibraryRef}}", + "from-library": "Updating query from library" + } }, "recently-deleted": { "buttons": {