mirror of https://github.com/grafana/grafana.git
				
				
				
			QueryLibrary: Move to enterprise (#100133)
This commit is contained in:
		
							parent
							
								
									ccb9cab131
								
							
						
					
					
						commit
						4b9fee61a8
					
				|  | @ -4701,27 +4701,6 @@ exports[`better eslint`] = { | |||
|       [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"], | ||||
|       [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"] | ||||
|     ], | ||||
|     "public/app/features/explore/QueryLibrary/QueryLibraryExpmInfo.tsx:5381": [ | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"], | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"], | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"], | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"] | ||||
|     ], | ||||
|     "public/app/features/explore/QueryLibrary/QueryTemplateForm.tsx:5381": [ | ||||
|       [0, 0, 0, "\'@grafana/ui/src/components/Input/Input\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] | ||||
|     ], | ||||
|     "public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx:5381": [ | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"], | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"], | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"], | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"], | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"], | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"] | ||||
|     ], | ||||
|     "public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx:5381": [ | ||||
|       [0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"], | ||||
|       [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"] | ||||
|     ], | ||||
|     "public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx:5381": [ | ||||
|       [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] | ||||
|     ], | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { Action, KBarProvider } from 'kbar'; | ||||
| import { Component, ComponentType, Fragment } from 'react'; | ||||
| import { Component, ComponentType, Fragment, ReactNode } from 'react'; | ||||
| import CacheProvider from 'react-inlinesvg/provider'; | ||||
| import { Provider } from 'react-redux'; | ||||
| import { Route, Routes } from 'react-router-dom-v5-compat'; | ||||
|  | @ -37,6 +37,11 @@ interface AppWrapperState { | |||
| /** Used by enterprise */ | ||||
| let bodyRenderHooks: ComponentType[] = []; | ||||
| let pageBanners: ComponentType[] = []; | ||||
| const enterpriseProviders: Array<ComponentType<{ children: ReactNode }>> = []; | ||||
| 
 | ||||
| export function addEnterpriseProviders(provider: ComponentType<{ children: ReactNode }>) { | ||||
|   enterpriseProviders.push(provider); | ||||
| } | ||||
| 
 | ||||
| export function addBodyRenderHook(fn: ComponentType) { | ||||
|   bodyRenderHooks.push(fn); | ||||
|  | @ -100,6 +105,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> { | |||
|       routes: ready && this.renderRoutes(), | ||||
|       pageBanners, | ||||
|       bodyRenderHooks, | ||||
|       providers: enterpriseProviders, | ||||
|     }; | ||||
| 
 | ||||
|     const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment; | ||||
|  |  | |||
|  | @ -309,7 +309,7 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat | |||
| export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<PanelDataQueriesTab>) { | ||||
|   const { datasource, dsSettings } = model.useState(); | ||||
|   const { data, queries } = model.queryRunner.useState(); | ||||
|   const { openDrawer: openQueryLibraryDrawer } = useQueryLibraryContext(); | ||||
|   const { openDrawer: openQueryLibraryDrawer, queryLibraryEnabled } = useQueryLibraryContext(); | ||||
| 
 | ||||
|   if (!datasource || !dsSettings || !data) { | ||||
|     return null; | ||||
|  | @ -355,7 +355,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel | |||
|             > | ||||
|               Add query | ||||
|             </Button> | ||||
|             {config.featureToggles.queryLibrary && ( | ||||
|             {queryLibraryEnabled && ( | ||||
|               <Button | ||||
|                 icon="plus" | ||||
|                 onClick={() => { | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ import { shallowEqual } from 'react-redux'; | |||
| 
 | ||||
| import { DataSourceInstanceSettings, RawTimeRange, GrafanaTheme2 } from '@grafana/data'; | ||||
| import { Components } from '@grafana/e2e-selectors'; | ||||
| import { config, reportInteraction } from '@grafana/runtime'; | ||||
| import { reportInteraction } from '@grafana/runtime'; | ||||
| import { | ||||
|   defaultIntervals, | ||||
|   PageToolbar, | ||||
|  | @ -29,6 +29,7 @@ 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'; | ||||
|  | @ -92,6 +93,7 @@ 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,7 +208,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle | |||
| 
 | ||||
|   const navBarActions = [<ShortLinkButtonMenu key="share" />]; | ||||
| 
 | ||||
|   if (config.featureToggles.queryLibrary) { | ||||
|   if (queryLibraryEnabled) { | ||||
|     navBarActions.unshift(<QueriesDrawerDropdown key="queryLibrary" variant="full" />); | ||||
|   } else { | ||||
|     navBarActions.unshift( | ||||
|  |  | |||
|  | @ -1,7 +1,6 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { ComponentProps, useState } from 'react'; | ||||
| 
 | ||||
| import { config } from '@grafana/runtime'; | ||||
| import { Button, ButtonGroup, Dropdown, Menu, ToolbarButton } from '@grafana/ui'; | ||||
| import { useStyles2 } from '@grafana/ui/'; | ||||
| import { t } from 'app/core/internationalization'; | ||||
|  | @ -9,7 +8,6 @@ import { t } from 'app/core/internationalization'; | |||
| import { createDatasourcesList } from '../../../core/utils/richHistory'; | ||||
| import { useSelector } from '../../../types'; | ||||
| import ExploreRunQueryButton from '../ExploreRunQueryButton'; | ||||
| import { queryLibraryTrackToggle } from '../QueryLibrary/QueryLibraryAnalyticsEvents'; | ||||
| import { useQueryLibraryContext } from '../QueryLibrary/QueryLibraryContext'; | ||||
| import { QueryActionButton } from '../QueryLibrary/types'; | ||||
| import { selectExploreDSMaps } from '../state/selectors'; | ||||
|  | @ -39,6 +37,7 @@ export function QueriesDrawerDropdown({ variant }: Props) { | |||
|     openDrawer: openQueryLibraryDrawer, | ||||
|     closeDrawer: closeQueryLibraryDrawer, | ||||
|     isDrawerOpen: isQueryLibraryDrawerOpen, | ||||
|     queryLibraryEnabled, | ||||
|   } = useQueryLibraryContext(); | ||||
| 
 | ||||
|   const [queryOption, setQueryOption] = useState<'library' | 'history'>('library'); | ||||
|  | @ -48,7 +47,7 @@ export function QueriesDrawerDropdown({ variant }: Props) { | |||
|   const styles = useStyles2(getStyles); | ||||
| 
 | ||||
|   // In case query library is not enabled we show only simple button for query history in the parent.
 | ||||
|   if (!config.featureToggles.queryLibrary) { | ||||
|   if (!queryLibraryEnabled) { | ||||
|     return undefined; | ||||
|   } | ||||
| 
 | ||||
|  | @ -73,7 +72,6 @@ export function QueriesDrawerDropdown({ variant }: Props) { | |||
| 
 | ||||
|       openQueryLibraryDrawer(activeDatasources, ExploreRunQueryButtonWrapper); | ||||
|     } | ||||
|     queryLibraryTrackToggle(!isQueryLibraryDrawerOpen); | ||||
|   } | ||||
| 
 | ||||
|   const menu = ( | ||||
|  |  | |||
|  | @ -1,36 +0,0 @@ | |||
| import { DataQuery } from '@grafana/schema'; | ||||
| import { Modal } from '@grafana/ui'; | ||||
| 
 | ||||
| import { t } from '../../../core/internationalization'; | ||||
| 
 | ||||
| import { queryLibraryTrackAddFromQueryRow } from './QueryLibraryAnalyticsEvents'; | ||||
| import { QueryTemplateForm } from './QueryTemplateForm'; | ||||
| 
 | ||||
| type Props = { | ||||
|   isOpen: boolean; | ||||
|   close: () => void; | ||||
|   query?: DataQuery; | ||||
| }; | ||||
| 
 | ||||
| export function AddToQueryLibraryModal({ query, close, isOpen }: Props) { | ||||
|   return ( | ||||
|     <Modal | ||||
|       title={t('explore.query-template-modal.add-title', 'Add query to Query Library')} | ||||
|       isOpen={isOpen} | ||||
|       onDismiss={() => close()} | ||||
|     > | ||||
|       <QueryTemplateForm | ||||
|         onCancel={() => { | ||||
|           close(); | ||||
|         }} | ||||
|         onSave={(isSuccess) => { | ||||
|           if (isSuccess) { | ||||
|             close(); | ||||
|             queryLibraryTrackAddFromQueryRow(query?.datasource?.type || ''); | ||||
|           } | ||||
|         }} | ||||
|         queryToAdd={query!} | ||||
|       /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | @ -1,36 +0,0 @@ | |||
| import { useLocalStorage } from 'react-use'; | ||||
| 
 | ||||
| import { QueryLibraryExpmInfo } from './QueryLibraryExpmInfo'; | ||||
| import { QueryTemplatesList } from './QueryTemplatesList'; | ||||
| import { QueryActionButton } from './types'; | ||||
| 
 | ||||
| export interface QueryLibraryProps { | ||||
|   // List of active datasources to filter the query library by
 | ||||
|   // E.g in Explore the active datasources are the datasources that are currently selected in the query editor
 | ||||
|   activeDatasources?: string[]; | ||||
|   queryActionButton?: QueryActionButton; | ||||
| } | ||||
| 
 | ||||
| export const QUERY_LIBRARY_LOCAL_STORAGE_KEYS = { | ||||
|   explore: { | ||||
|     notifyUserAboutQueryLibrary: 'grafana.explore.query-library.notifyUserAboutQueryLibrary', | ||||
|     newButton: 'grafana.explore.query-library.newButton', | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| export function QueryLibrary({ activeDatasources, queryActionButton }: QueryLibraryProps) { | ||||
|   const [notifyUserAboutQueryLibrary, setNotifyUserAboutQueryLibrary] = useLocalStorage( | ||||
|     QUERY_LIBRARY_LOCAL_STORAGE_KEYS.explore.notifyUserAboutQueryLibrary, | ||||
|     true | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <QueryLibraryExpmInfo | ||||
|         isOpen={notifyUserAboutQueryLibrary || false} | ||||
|         onDismiss={() => setNotifyUserAboutQueryLibrary(false)} | ||||
|       /> | ||||
|       <QueryTemplatesList activeDatasources={activeDatasources} queryActionButton={queryActionButton} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | @ -1,56 +0,0 @@ | |||
| import { reportInteraction } from '@grafana/runtime'; | ||||
| 
 | ||||
| const QUERY_LIBRARY_EXPLORE_EVENT = 'query_library_explore_clicked'; | ||||
| 
 | ||||
| export function queryLibraryTrackToggle(open: boolean) { | ||||
|   reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, { | ||||
|     item: 'query_library_toggle', | ||||
|     type: open ? 'open' : 'close', | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function queryLibraryTrackAddFromQueryHistory(datasourceType: string) { | ||||
|   reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, { | ||||
|     item: 'add_query_from_query_history', | ||||
|     type: datasourceType, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function queryLibraryTrackAddFromQueryHistoryAddModalShown() { | ||||
|   reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, { | ||||
|     item: 'add_query_modal_from_query_history', | ||||
|     type: 'open', | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function queryLibraryTrackAddFromQueryRow(datasourceType: string) { | ||||
|   reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, { | ||||
|     item: 'add_query_from_query_row', | ||||
|     type: datasourceType, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function queryLibaryTrackDeleteQuery() { | ||||
|   reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, { | ||||
|     item: 'delete_query', | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function queryLibraryTrackRunQuery(datasourceType: string) { | ||||
|   reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, { | ||||
|     item: 'run_query', | ||||
|     type: datasourceType, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function queryLibraryTrackAddOrEditDescription() { | ||||
|   reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, { | ||||
|     item: 'add_or_edit_description', | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function queryLibraryTrackFilterDatasource() { | ||||
|   reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, { | ||||
|     item: 'filter_datasource', | ||||
|   }); | ||||
| } | ||||
|  | @ -1,79 +0,0 @@ | |||
| import { act, render, screen, waitFor } from '@testing-library/react'; | ||||
| import { ComponentType } from 'react'; | ||||
| 
 | ||||
| import { PromQuery } from '@grafana/prometheus'; | ||||
| 
 | ||||
| import { useQueryLibraryContext, QueryLibraryContextProvider, QueryLibraryContextType } from './QueryLibraryContext'; | ||||
| 
 | ||||
| // Bit of mocking here mainly so we don't have to mock too much of the API calls here and keep this test focused on the
 | ||||
| // context state management and correct rendering.
 | ||||
| 
 | ||||
| jest.mock('./AddToQueryLibraryModal', () => ({ | ||||
|   __esModule: true, | ||||
|   AddToQueryLibraryModal: (props: { isOpen: boolean; query: unknown }) => | ||||
|     props.isOpen && <div>QUERY_MODAL {JSON.stringify(props.query)}</div>, | ||||
| })); | ||||
| 
 | ||||
| jest.mock('./QueryLibraryDrawer', () => ({ | ||||
|   __esModule: true, | ||||
|   QueryLibraryDrawer: (props: { | ||||
|     isOpen: boolean; | ||||
|     activeDatasources: string[] | undefined; | ||||
|     queryActionButton: ComponentType; | ||||
|   }) => | ||||
|     props.isOpen && ( | ||||
|       <div> | ||||
|         QUERY_DRAWER {JSON.stringify(props.activeDatasources)} {props.queryActionButton && <props.queryActionButton />} | ||||
|       </div> | ||||
|     ), | ||||
| })); | ||||
| 
 | ||||
| function setup() { | ||||
|   let ctx: { current: QueryLibraryContextType | undefined } = { current: undefined }; | ||||
|   function TestComp() { | ||||
|     ctx.current = useQueryLibraryContext(); | ||||
|     return <div></div>; | ||||
|   } | ||||
|   // rendering instead of just using renderHook so we can check if the modal and drawer actually render.
 | ||||
|   const renderResult = render( | ||||
|     <QueryLibraryContextProvider> | ||||
|       <TestComp /> | ||||
|     </QueryLibraryContextProvider> | ||||
|   ); | ||||
| 
 | ||||
|   return { ctx, renderResult }; | ||||
| } | ||||
| 
 | ||||
| describe('QueryLibraryContext', () => { | ||||
|   it('should not render modal or drawer by default', () => { | ||||
|     setup(); | ||||
|     // should catch both modal and drawer
 | ||||
|     expect(screen.queryByText(/QUERY_MODAL/i)).not.toBeInTheDocument(); | ||||
|     expect(screen.queryByText(/QUERY_DRAWER/i)).not.toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   it('should be able to open modal', async () => { | ||||
|     const { ctx } = setup(); | ||||
|     act(() => { | ||||
|       ctx.current!.openAddQueryModal({ refId: 'A', expr: 'http_requests_total{job="test"}' } as PromQuery); | ||||
|     }); | ||||
| 
 | ||||
|     await waitFor(() => { | ||||
|       expect(screen.queryByText(/QUERY_MODAL/i)).toBeInTheDocument(); | ||||
|       expect(screen.queryByText(/http_requests_total\{job=\\"test\\"}/i)).toBeInTheDocument(); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('should be able to open drawer', async () => { | ||||
|     const { ctx } = setup(); | ||||
|     act(() => { | ||||
|       ctx.current!.openDrawer(['PROM_TEST_DS'], () => <div>QUERY_ACTION_BUTTON</div>); | ||||
|     }); | ||||
| 
 | ||||
|     await waitFor(() => { | ||||
|       expect(screen.queryByText(/QUERY_DRAWER/i)).toBeInTheDocument(); | ||||
|       expect(screen.queryByText(/PROM_TEST_DS/i)).toBeInTheDocument(); | ||||
|       expect(screen.queryByText(/QUERY_ACTION_BUTTON/i)).toBeInTheDocument(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -1,10 +1,8 @@ | |||
| import { PropsWithChildren, useState, createContext, useContext, useCallback, useMemo } from 'react'; | ||||
| import { createContext, ReactNode, useContext } from 'react'; | ||||
| 
 | ||||
| import { DataQuery } from '@grafana/schema'; | ||||
| 
 | ||||
| import { AddToQueryLibraryModal } from './AddToQueryLibraryModal'; | ||||
| import { QueryLibraryDrawer } from './QueryLibraryDrawer'; | ||||
| import { QueryActionButton, QueryActionButtonProps } from './types'; | ||||
| import { QueryActionButton } from './types'; | ||||
| 
 | ||||
| /** | ||||
|  * Context with state and action to interact with Query Library. The Query Library feature consists of a drawer | ||||
|  | @ -19,17 +17,33 @@ export type QueryLibraryContextType = { | |||
|    * @param datasourceFilters Data source names that will be used for initial filter in the library. | ||||
|    * @param queryActionButton Action button will be shown in the library next to the query and can implement context | ||||
|    *   specific actions with the library, like running the query or updating some query in the current app. | ||||
|    * @param options.context Used for tracking. Should identify the context this is called from, like 'explore' or | ||||
|    *   'dashboard'. | ||||
|    */ | ||||
|   openDrawer: (datasourceFilters: string[], queryActionButton: QueryActionButton) => void; | ||||
|   openDrawer: ( | ||||
|     datasourceFilters: string[], | ||||
|     queryActionButton: QueryActionButton, | ||||
|     options?: { context?: string } | ||||
|   ) => void; | ||||
|   closeDrawer: () => void; | ||||
|   isDrawerOpen: boolean; | ||||
| 
 | ||||
|   /** | ||||
|    * Opens a modal for adding a query to the library. | ||||
|    * @param query Query to be saved | ||||
|    * @param options.onSave Callback that will be called after the query is saved. | ||||
|    * @param options.context Used for tracking. Should identify the context this is called from, like 'explore' or | ||||
|    *   'dashboard'. | ||||
|    */ | ||||
|   openAddQueryModal: (query: DataQuery, options?: { onSave?: () => void; context?: string }) => void; | ||||
|   closeAddQueryModal: () => void; | ||||
| 
 | ||||
|   /** | ||||
|    * Returns a predefined small button that can be used to save a query to the library. | ||||
|    * @param query | ||||
|    */ | ||||
|   openAddQueryModal: (query: DataQuery) => void; | ||||
|   closeAddQueryModal: () => void; | ||||
|   renderSaveQueryButton: (query: DataQuery) => ReactNode; | ||||
|   queryLibraryEnabled: boolean; | ||||
| }; | ||||
| 
 | ||||
| export const QueryLibraryContext = createContext<QueryLibraryContextType>({ | ||||
|  | @ -39,80 +53,14 @@ export const QueryLibraryContext = createContext<QueryLibraryContextType>({ | |||
| 
 | ||||
|   openAddQueryModal: () => {}, | ||||
|   closeAddQueryModal: () => {}, | ||||
| 
 | ||||
|   renderSaveQueryButton: () => { | ||||
|     return null; | ||||
|   }, | ||||
| 
 | ||||
|   queryLibraryEnabled: false, | ||||
| }); | ||||
| 
 | ||||
| export function useQueryLibraryContext() { | ||||
|   return useContext(QueryLibraryContext); | ||||
| } | ||||
| 
 | ||||
| export function QueryLibraryContextProvider({ children }: PropsWithChildren) { | ||||
|   const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false); | ||||
|   const [activeDatasources, setActiveDatasources] = useState<string[]>([]); | ||||
|   const [isAddQueryModalOpen, setIsAddQueryModalOpen] = useState<boolean>(false); | ||||
|   const [activeQuery, setActiveQuery] = useState<DataQuery | undefined>(undefined); | ||||
|   const [queryActionButton, setQueryActionButton] = useState<QueryActionButton | undefined>(undefined); | ||||
| 
 | ||||
|   const openDrawer = useCallback((datasourceFilters: string[], queryActionButton: QueryActionButton) => { | ||||
|     setActiveDatasources(datasourceFilters); | ||||
|     // Because the queryActionButton can be a function component it would be called as a callback if just passed in.
 | ||||
|     setQueryActionButton(() => queryActionButton); | ||||
|     setIsDrawerOpen(true); | ||||
|   }, []); | ||||
| 
 | ||||
|   const closeDrawer = useCallback(() => { | ||||
|     setActiveDatasources([]); | ||||
|     setQueryActionButton(undefined); | ||||
|     setIsDrawerOpen(false); | ||||
|   }, []); | ||||
| 
 | ||||
|   const openAddQueryModal = useCallback((query: DataQuery) => { | ||||
|     setActiveQuery(query); | ||||
|     setIsAddQueryModalOpen(true); | ||||
|   }, []); | ||||
| 
 | ||||
|   const closeAddQueryModal = useCallback(() => { | ||||
|     setActiveQuery(undefined); | ||||
|     setIsAddQueryModalOpen(false); | ||||
|   }, []); | ||||
| 
 | ||||
|   // We wrap the action button one time to add the closeDrawer behaviour. This way whoever injects the action button
 | ||||
|   // does not need to remember to do it nor the query table inside that renders it needs to know about the drawer.
 | ||||
|   const finalActionButton = useMemo(() => { | ||||
|     if (!queryActionButton) { | ||||
|       return queryActionButton; | ||||
|     } | ||||
|     return (props: QueryActionButtonProps) => { | ||||
|       const QButton = queryActionButton; | ||||
|       return ( | ||||
|         <QButton | ||||
|           {...props} | ||||
|           onClick={() => { | ||||
|             props.onClick(); | ||||
|             closeDrawer(); | ||||
|           }} | ||||
|         /> | ||||
|       ); | ||||
|     }; | ||||
|   }, [closeDrawer, queryActionButton]); | ||||
| 
 | ||||
|   return ( | ||||
|     <QueryLibraryContext.Provider | ||||
|       value={{ | ||||
|         isDrawerOpen, | ||||
|         openDrawer, | ||||
|         closeDrawer, | ||||
|         openAddQueryModal, | ||||
|         closeAddQueryModal, | ||||
|       }} | ||||
|     > | ||||
|       {children} | ||||
|       <QueryLibraryDrawer | ||||
|         isOpen={isDrawerOpen} | ||||
|         close={closeDrawer} | ||||
|         activeDatasources={activeDatasources} | ||||
|         queryActionButton={finalActionButton} | ||||
|       /> | ||||
|       <AddToQueryLibraryModal isOpen={isAddQueryModalOpen} close={closeAddQueryModal} query={activeQuery} /> | ||||
|     </QueryLibraryContext.Provider> | ||||
|   ); | ||||
| } | ||||
|  |  | |||
|  | @ -1,53 +0,0 @@ | |||
| import { skipToken } from '@reduxjs/toolkit/query/react'; | ||||
| 
 | ||||
| import { selectors } from '@grafana/e2e-selectors'; | ||||
| import { TabbedContainer, TabConfig } from '@grafana/ui'; | ||||
| 
 | ||||
| import { t } from '../../../core/internationalization'; | ||||
| import { useListQueryTemplateQuery } from '../../query-library'; | ||||
| import { QUERY_LIBRARY_GET_LIMIT } from '../../query-library/api/api'; | ||||
| import { ExploreDrawer } from '../ExploreDrawer'; | ||||
| 
 | ||||
| import { QueryLibrary } from './QueryLibrary'; | ||||
| import { QueryActionButton } from './types'; | ||||
| 
 | ||||
| type Props = { | ||||
|   isOpen: boolean; | ||||
|   // List of datasource names to filter query templates by
 | ||||
|   activeDatasources: string[] | undefined; | ||||
|   close: () => void; | ||||
|   queryActionButton?: QueryActionButton; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Drawer with query library feature. Handles its own state and should be included in some top level component. | ||||
|  */ | ||||
| export function QueryLibraryDrawer({ isOpen, activeDatasources, close, queryActionButton }: Props) { | ||||
|   const { data } = useListQueryTemplateQuery(isOpen ? {} : skipToken); | ||||
|   const queryTemplatesCount = data?.items?.length ?? 0; | ||||
| 
 | ||||
|   // TODO: the tabbed container is here mainly for close button and some margins maybe make sense to use something
 | ||||
|   //  else as there is only one tab.
 | ||||
|   const tabs: TabConfig[] = [ | ||||
|     { | ||||
|       label: `${t('explore.rich-history.query-library', 'Query library')} (${queryTemplatesCount}/${QUERY_LIBRARY_GET_LIMIT})`, | ||||
|       value: 'Query library', | ||||
|       content: <QueryLibrary activeDatasources={activeDatasources} queryActionButton={queryActionButton} />, | ||||
|       icon: 'book', | ||||
|     }, | ||||
|   ]; | ||||
| 
 | ||||
|   return ( | ||||
|     isOpen && ( | ||||
|       <ExploreDrawer initialHeight={'75vh'}> | ||||
|         <TabbedContainer | ||||
|           tabs={tabs} | ||||
|           onClose={close} | ||||
|           defaultTab={'Query library'} | ||||
|           closeIconTooltip={t('explore.rich-history.close-tooltip', 'Close query history')} | ||||
|           testId={selectors.pages.Explore.QueryHistory.container} | ||||
|         /> | ||||
|       </ExploreDrawer> | ||||
|     ) | ||||
|   ); | ||||
| } | ||||
|  | @ -1,23 +0,0 @@ | |||
| import { Alert, Modal } from '@grafana/ui'; | ||||
| 
 | ||||
| interface Props { | ||||
|   isOpen: boolean; | ||||
|   onDismiss: () => void; | ||||
| } | ||||
| 
 | ||||
| export function QueryLibraryExpmInfo({ isOpen, onDismiss }: Props) { | ||||
|   return ( | ||||
|     <Modal title="Query Library" isOpen={isOpen} onDismiss={onDismiss}> | ||||
|       <Alert | ||||
|         severity="info" | ||||
|         title="Query library is in the experimental mode. It is a place where you can save your queries and share them with | ||||
|     your team. Once you save a query, it will be available for the whole organization to use." | ||||
|       /> | ||||
|       <Alert severity="info" title=" Currently we are limiting the number of saved queries per organization to 1000." /> | ||||
|       <Alert | ||||
|         severity="warning" | ||||
|         title="Although it's unlikely, some data loss may occur during the experimental phase." | ||||
|       /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | @ -1,180 +0,0 @@ | |||
| import { useForm } from 'react-hook-form'; | ||||
| import { useAsync } from 'react-use'; | ||||
| 
 | ||||
| import { AppEvents, dateTime } from '@grafana/data'; | ||||
| import { DataSourcePicker, getAppEvents, getDataSourceSrv } from '@grafana/runtime'; | ||||
| import { DataQuery } from '@grafana/schema'; | ||||
| import { Button, InlineSwitch, Modal, RadioButtonGroup, TextArea } from '@grafana/ui'; | ||||
| import { Field } from '@grafana/ui/'; | ||||
| import { Input } from '@grafana/ui/src/components/Input/Input'; | ||||
| import { Trans, t } from 'app/core/internationalization'; | ||||
| import { getQueryDisplayText } from 'app/core/utils/richHistory'; | ||||
| import { useCreateQueryTemplateMutation, useUpdateQueryTemplateMutation } from 'app/features/query-library'; | ||||
| import { AddQueryTemplateCommand, EditQueryTemplateCommand } from 'app/features/query-library/types'; | ||||
| 
 | ||||
| import { convertAddQueryTemplateCommandToDataQuerySpec } from '../../query-library/api/mappers'; | ||||
| import { useDatasource } from '../QueryLibrary/utils/useDatasource'; | ||||
| 
 | ||||
| import { QueryTemplateRow } from './QueryTemplatesTable/types'; | ||||
| 
 | ||||
| type Props = { | ||||
|   onCancel: () => void; | ||||
|   onSave: (isSuccess: boolean) => void; | ||||
|   queryToAdd?: DataQuery; | ||||
|   templateData?: QueryTemplateRow; | ||||
| }; | ||||
| 
 | ||||
| export type QueryDetails = { | ||||
|   description: string; | ||||
| }; | ||||
| 
 | ||||
| const getInstuctions = (isAdd: boolean) => { | ||||
|   return isAdd | ||||
|     ? t( | ||||
|         'explore.query-template-modal.add-info', | ||||
|         `You're about to save this query. Once saved, you can easily access it in the Query Library tab for future use and reference.` | ||||
|       ) | ||||
|     : t( | ||||
|         'explore.query-template-modal.edit-info', | ||||
|         `You're about to edit this query. Once saved, you can easily access it in the Query Library tab for future use and reference.` | ||||
|       ); | ||||
| }; | ||||
| 
 | ||||
| export const QueryTemplateForm = ({ onCancel, onSave, queryToAdd, templateData }: Props) => { | ||||
|   const { register, handleSubmit } = useForm<QueryDetails>({ | ||||
|     defaultValues: { | ||||
|       description: templateData?.description, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   const [addQueryTemplate] = useCreateQueryTemplateMutation(); | ||||
|   const [editQueryTemplate] = useUpdateQueryTemplateMutation(); | ||||
| 
 | ||||
|   const datasource = useDatasource(queryToAdd?.datasource); | ||||
| 
 | ||||
|   // this is an array to support multi query templates sometime in the future
 | ||||
|   const queries = | ||||
|     queryToAdd !== undefined ? [queryToAdd] : templateData?.query !== undefined ? [templateData?.query] : []; | ||||
| 
 | ||||
|   const handleAddQueryTemplate = async (addQueryTemplateCommand: AddQueryTemplateCommand) => { | ||||
|     return addQueryTemplate({ | ||||
|       queryTemplate: convertAddQueryTemplateCommandToDataQuerySpec(addQueryTemplateCommand), | ||||
|     }) | ||||
|       .unwrap() | ||||
|       .then(() => { | ||||
|         getAppEvents().publish({ | ||||
|           type: AppEvents.alertSuccess.name, | ||||
|           payload: [t('explore.query-library.query-template-added', 'Query successfully saved to the library')], | ||||
|         }); | ||||
|         return true; | ||||
|       }) | ||||
|       .catch(() => { | ||||
|         getAppEvents().publish({ | ||||
|           type: AppEvents.alertError.name, | ||||
|           payload: [ | ||||
|             t('explore.query-library.query-template-add-error', 'Error attempting to save this query to the library'), | ||||
|           ], | ||||
|         }); | ||||
|         return false; | ||||
|       }); | ||||
|   }; | ||||
| 
 | ||||
|   const handleEditQueryTemplate = async (editQueryTemplateCommand: EditQueryTemplateCommand) => { | ||||
|     return editQueryTemplate({ | ||||
|       name: editQueryTemplateCommand.uid, | ||||
|       patch: { | ||||
|         spec: editQueryTemplateCommand.partialSpec, | ||||
|       }, | ||||
|     }) | ||||
|       .unwrap() | ||||
|       .then(() => { | ||||
|         getAppEvents().publish({ | ||||
|           type: AppEvents.alertSuccess.name, | ||||
|           payload: [t('explore.query-library.query-template-edited', 'Query template successfully edited')], | ||||
|         }); | ||||
|         return true; | ||||
|       }) | ||||
|       .catch(() => { | ||||
|         getAppEvents().publish({ | ||||
|           type: AppEvents.alertError.name, | ||||
|           payload: [t('explore.query-library.query-template-edit-error', 'Error attempting to edit this query')], | ||||
|         }); | ||||
|         return false; | ||||
|       }); | ||||
|   }; | ||||
| 
 | ||||
|   const onSubmit = async (data: QueryDetails) => { | ||||
|     const timestamp = dateTime().toISOString(); | ||||
|     const temporaryDefaultTitle = | ||||
|       data.description || t('explore.query-library.default-description', 'Public', { timestamp: timestamp }); | ||||
| 
 | ||||
|     if (templateData?.uid) { | ||||
|       handleEditQueryTemplate({ uid: templateData.uid, partialSpec: { title: data.description } }).then((isSuccess) => { | ||||
|         onSave(isSuccess); | ||||
|       }); | ||||
|     } else if (queryToAdd) { | ||||
|       handleAddQueryTemplate({ title: temporaryDefaultTitle, targets: [queryToAdd] }).then((isSuccess) => { | ||||
|         onSave(isSuccess); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const { value: queryText } = useAsync(async () => { | ||||
|     const promises = queries.map(async (query, i) => { | ||||
|       const datasource = await getDataSourceSrv().get(query.datasource); | ||||
|       return datasource?.getQueryDisplayText?.(query) || getQueryDisplayText(query); | ||||
|     }); | ||||
|     return Promise.all(promises); | ||||
|   }); | ||||
| 
 | ||||
|   return ( | ||||
|     <form onSubmit={handleSubmit(onSubmit)}> | ||||
|       <p>{getInstuctions(templateData === undefined)}</p> | ||||
|       {queryText && | ||||
|         queryText.map((queryString, i) => ( | ||||
|           <Field key={`query-${i}`} label={t('explore.query-template-modal.query', 'Query')}> | ||||
|             <TextArea readOnly={true} value={queryString}></TextArea> | ||||
|           </Field> | ||||
|         ))} | ||||
|       {queryToAdd && ( | ||||
|         <> | ||||
|           <Field label={t('explore.query-template-modal.data-source-name', 'Data source name')}> | ||||
|             <DataSourcePicker current={datasource?.uid} disabled={true} /> | ||||
|           </Field> | ||||
|           <Field label={t('explore.query-template-modall.data-source-type', 'Data source type')}> | ||||
|             <Input disabled={true} defaultValue={datasource?.meta.name}></Input> | ||||
|           </Field> | ||||
|         </> | ||||
|       )} | ||||
|       <Field label={t('explore.query-template-modal.description', 'Description')}> | ||||
|         <Input id="query-template-description" autoFocus={true} {...register('description')}></Input> | ||||
|       </Field> | ||||
|       <Field label={t('explore.query-template-modal.visibility', 'Visibility')}> | ||||
|         <RadioButtonGroup | ||||
|           options={[ | ||||
|             { value: 'Public', label: t('explore.query-library.public', 'Public') }, | ||||
|             { value: 'Private', label: t('explore.query-library.private', 'Private') }, | ||||
|           ]} | ||||
|           value={'Public'} | ||||
|           disabled={true} | ||||
|         /> | ||||
|       </Field> | ||||
|       <InlineSwitch | ||||
|         showLabel={true} | ||||
|         disabled={true} | ||||
|         label={t( | ||||
|           'explore.query-template-modal.auto-star', | ||||
|           'Auto-star this query to add it to your starred list in the Query Library.' | ||||
|         )} | ||||
|       /> | ||||
|       <Modal.ButtonRow> | ||||
|         <Button variant="secondary" onClick={() => onCancel()} fill="outline"> | ||||
|           <Trans i18nKey="explore.query-library.cancel">Cancel</Trans> | ||||
|         </Button> | ||||
|         <Button variant="primary" type="submit"> | ||||
|           <Trans i18nKey="explore.query-library.save">Save</Trans> | ||||
|         </Button> | ||||
|       </Modal.ButtonRow> | ||||
|     </form> | ||||
|   ); | ||||
| }; | ||||
|  | @ -1,139 +0,0 @@ | |||
| import { render, waitFor, screen } from '@testing-library/react'; | ||||
| 
 | ||||
| import { AnnoKeyCreatedBy } from '../../apiserver/types'; | ||||
| import { ListQueryTemplateApiResponse } from '../../query-library/api/endpoints.gen'; | ||||
| 
 | ||||
| import { QueryTemplatesList } from './QueryTemplatesList'; | ||||
| import { QueryActionButtonProps } from './types'; | ||||
| 
 | ||||
| let data: ListQueryTemplateApiResponse = { | ||||
|   items: [], | ||||
| }; | ||||
| 
 | ||||
| jest.mock('app/features/query-library', () => { | ||||
|   const actual = jest.requireActual('app/features/query-library'); | ||||
|   return { | ||||
|     ...actual, | ||||
|     useDeleteQueryTemplateMutation: () => [() => {}], | ||||
|     useListQueryTemplateQuery: () => { | ||||
|       return { | ||||
|         data: data, | ||||
|         isLoading: false, | ||||
|         error: null, | ||||
|       }; | ||||
|     }, | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| jest.mock('./utils/dataFetching', () => { | ||||
|   return { | ||||
|     __esModule: true, | ||||
|     useLoadQueryMetadata: () => { | ||||
|       return { | ||||
|         loading: false, | ||||
|         value: [ | ||||
|           { | ||||
|             index: '0', | ||||
|             uid: '0', | ||||
|             datasourceName: 'prometheus', | ||||
|             datasourceRef: { type: 'prometheus', uid: 'Prometheus0' }, | ||||
|             datasourceType: 'prometheus', | ||||
|             createdAtTimestamp: 0, | ||||
|             query: { refId: 'A' }, | ||||
|             queryText: 'http_requests_total{job="test"}', | ||||
|             description: 'template0', | ||||
|             user: { | ||||
|               uid: 'viewer:JohnDoe', | ||||
|               displayName: 'John Doe', | ||||
|               avatarUrl: '', | ||||
|             }, | ||||
|             error: undefined, | ||||
|           }, | ||||
|         ], | ||||
|       }; | ||||
|     }, | ||||
|     useLoadUsers: () => { | ||||
|       return { | ||||
|         value: { | ||||
|           display: [ | ||||
|             { | ||||
|               avatarUrl: '', | ||||
|               displayName: 'john doe', | ||||
|               identity: { | ||||
|                 name: 'JohnDoe', | ||||
|                 type: 'viewer', | ||||
|               }, | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|         loading: false, | ||||
|         error: null, | ||||
|       }; | ||||
|     }, | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| describe('QueryTemplatesList', () => { | ||||
|   it('renders empty state', async () => { | ||||
|     data = {}; | ||||
|     render(<QueryTemplatesList />); | ||||
|     await waitFor(() => { | ||||
|       expect(screen.getByText(/You haven't saved any queries to your library yet/)).toBeInTheDocument(); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('renders query', async () => { | ||||
|     data.items = testItems; | ||||
|     render(<QueryTemplatesList />); | ||||
|     await waitFor(() => { | ||||
|       // We don't really show query template title for some reason so creator name
 | ||||
|       expect(screen.getByText(/John Doe/)).toBeInTheDocument(); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('renders actionButton for query', async () => { | ||||
|     data.items = testItems; | ||||
|     let passedProps: QueryActionButtonProps; | ||||
| 
 | ||||
|     const queryActionButton = (props: QueryActionButtonProps) => { | ||||
|       passedProps = props; | ||||
|       return <button>TEST_ACTION_BUTTON</button>; | ||||
|     }; | ||||
| 
 | ||||
|     render(<QueryTemplatesList queryActionButton={queryActionButton} />); | ||||
|     await waitFor(() => { | ||||
|       // We don't really show query template title for some reason so creator name
 | ||||
|       expect(screen.getByText(/John Doe/)).toBeInTheDocument(); | ||||
|       expect(screen.getByText(/TEST_ACTION_BUTTON/)).toBeInTheDocument(); | ||||
|       // We didn't put much else into the query object but should be enough to check the prop
 | ||||
|       expect(passedProps.queries).toMatchObject([{ refId: 'A' }]); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| const testItems = [ | ||||
|   { | ||||
|     metadata: { | ||||
|       name: 'TEST_QUERY', | ||||
|       creationTimestamp: '2025-01-01T11:11:11.00Z', | ||||
|       annotations: { | ||||
|         [AnnoKeyCreatedBy]: 'viewer:JohnDoe', | ||||
|       }, | ||||
|     }, | ||||
|     spec: { | ||||
|       title: 'Test Query title', | ||||
|       targets: [ | ||||
|         { | ||||
|           variables: {}, | ||||
|           properties: { | ||||
|             refId: 'A', | ||||
|             datasource: { | ||||
|               uid: 'Prometheus', | ||||
|               type: 'prometheus', | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|   }, | ||||
| ]; | ||||
|  | @ -1,222 +0,0 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { uniqBy } from 'lodash'; | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| 
 | ||||
| import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; | ||||
| import { getAppEvents } from '@grafana/runtime'; | ||||
| import { EmptyState, FilterInput, InlineLabel, MultiSelect, Spinner, useStyles2, Stack, Badge } from '@grafana/ui'; | ||||
| import { t, Trans } from 'app/core/internationalization'; | ||||
| import { useListQueryTemplateQuery } from 'app/features/query-library'; | ||||
| import { QueryTemplate } from 'app/features/query-library/types'; | ||||
| 
 | ||||
| import { convertDataQueryResponseToQueryTemplates } from '../../query-library/api/mappers'; | ||||
| 
 | ||||
| import { QueryLibraryProps } from './QueryLibrary'; | ||||
| import { queryLibraryTrackFilterDatasource } from './QueryLibraryAnalyticsEvents'; | ||||
| import { QueryLibraryExpmInfo } from './QueryLibraryExpmInfo'; | ||||
| import QueryTemplatesTable from './QueryTemplatesTable'; | ||||
| import { useLoadQueryMetadata, useLoadUsers } from './utils/dataFetching'; | ||||
| import { searchQueryLibrary } from './utils/search'; | ||||
| 
 | ||||
| interface QueryTemplatesListProps extends QueryLibraryProps {} | ||||
| 
 | ||||
| export function QueryTemplatesList(props: QueryTemplatesListProps) { | ||||
|   const { data: rawData, isLoading, error } = useListQueryTemplateQuery({}); | ||||
|   const data = useMemo(() => (rawData ? convertDataQueryResponseToQueryTemplates(rawData) : undefined), [rawData]); | ||||
|   const [isModalOpen, setIsModalOpen] = useState(false); | ||||
|   const [searchQuery, setSearchQuery] = useState(''); | ||||
|   const [datasourceFilters, setDatasourceFilters] = useState<Array<SelectableValue<string>>>( | ||||
|     props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || [] | ||||
|   ); | ||||
|   const [userFilters, setUserFilters] = useState<Array<SelectableValue<string>>>([]); | ||||
|   const styles = useStyles2(getStyles); | ||||
| 
 | ||||
|   const loadUsersResult = useLoadUsersWithError(data); | ||||
|   const userNames = loadUsersResult.data ? loadUsersResult.data.display.map((user) => user.displayName) : []; | ||||
| 
 | ||||
|   const loadQueryMetadataResult = useLoadQueryMetadataWithError(data, loadUsersResult.data); | ||||
| 
 | ||||
|   // Filtering right now is done just on the frontend until there is better backend support for this.
 | ||||
|   const filteredRows = useMemo( | ||||
|     () => | ||||
|       searchQueryLibrary( | ||||
|         loadQueryMetadataResult.value || [], | ||||
|         searchQuery, | ||||
|         datasourceFilters.map((f) => f.value || ''), | ||||
|         userFilters.map((f) => f.value || '') | ||||
|       ), | ||||
|     [loadQueryMetadataResult.value, searchQuery, datasourceFilters, userFilters] | ||||
|   ); | ||||
| 
 | ||||
|   const datasourceNames = useMemo(() => { | ||||
|     return uniqBy(loadQueryMetadataResult.value, 'datasourceName').map((row) => row.datasourceName); | ||||
|   }, [loadQueryMetadataResult.value]); | ||||
| 
 | ||||
|   if (error instanceof Error) { | ||||
|     return ( | ||||
|       <EmptyState variant="not-found" message={`Something went wrong`}> | ||||
|         {error.message} | ||||
|       </EmptyState> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (isLoading || loadUsersResult.isLoading || loadQueryMetadataResult.loading) { | ||||
|     return <Spinner />; | ||||
|   } | ||||
| 
 | ||||
|   if (!data || data.length === 0) { | ||||
|     return ( | ||||
|       <EmptyState message={`Query Library`} variant="not-found"> | ||||
|         <p> | ||||
|           { | ||||
|             "You haven't saved any queries to your library yet. Start adding them from Explore or your Query History tab." | ||||
|           } | ||||
|         </p> | ||||
|       </EmptyState> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <QueryLibraryExpmInfo isOpen={isModalOpen} onDismiss={() => setIsModalOpen(false)} /> | ||||
|       <Stack gap={0.5}> | ||||
|         <FilterInput | ||||
|           className={styles.searchInput} | ||||
|           placeholder={t('query-library.search', 'Search by data source, query content or description')} | ||||
|           aria-label={t('query-library.search', 'Search by data source, query content or description')} | ||||
|           value={searchQuery} | ||||
|           onChange={(query) => setSearchQuery(query)} | ||||
|           escapeRegex={false} | ||||
|         /> | ||||
|         <InlineLabel className={styles.label} width="auto"> | ||||
|           <Trans i18nKey="query-library.datasource-names">Datasource name(s):</Trans> | ||||
|         </InlineLabel> | ||||
|         <MultiSelect | ||||
|           className={styles.multiSelect} | ||||
|           onChange={(items, actionMeta) => { | ||||
|             setDatasourceFilters(items); | ||||
|             actionMeta.action === 'select-option' && queryLibraryTrackFilterDatasource(); | ||||
|           }} | ||||
|           value={datasourceFilters} | ||||
|           options={datasourceNames.map((r) => { | ||||
|             return { value: r, label: r }; | ||||
|           })} | ||||
|           placeholder={'Filter queries for data sources(s)'} | ||||
|           aria-label={'Filter queries for data sources(s)'} | ||||
|         /> | ||||
|         <InlineLabel className={styles.label} width="auto"> | ||||
|           <Trans i18nKey="query-library.user-names">User name(s):</Trans> | ||||
|         </InlineLabel> | ||||
|         <MultiSelect | ||||
|           isLoading={loadUsersResult.isLoading} | ||||
|           className={styles.multiSelect} | ||||
|           onChange={(items, actionMeta) => { | ||||
|             setUserFilters(items); | ||||
|             actionMeta.action === 'select-option' && queryLibraryTrackFilterDatasource(); | ||||
|           }} | ||||
|           value={userFilters} | ||||
|           options={userNames.map((r) => { | ||||
|             return { value: r, label: r }; | ||||
|           })} | ||||
|           placeholder={'Filter queries for user name(s)'} | ||||
|           aria-label={'Filter queries for user name(s)'} | ||||
|         /> | ||||
|         <Badge | ||||
|           text="" | ||||
|           icon="info" | ||||
|           aria-label="info" | ||||
|           tooltip={'Click here for more informationn about Query library'} | ||||
|           color="blue" | ||||
|           style={{ cursor: 'pointer' }} | ||||
|           onClick={() => setIsModalOpen(true)} | ||||
|         /> | ||||
|       </Stack> | ||||
|       <QueryTemplatesTable queryTemplateRows={filteredRows} queryActionButton={props.queryActionButton} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Wrap useLoadUsers with error handling. | ||||
|  * @param data | ||||
|  */ | ||||
| function useLoadUsersWithError(data: QueryTemplate[] | undefined) { | ||||
|   const userUIDs = useMemo(() => data?.map((qt) => qt.user?.uid).filter((uid) => uid !== undefined), [data]); | ||||
|   const loadUsersResult = useLoadUsers(userUIDs); | ||||
|   useEffect(() => { | ||||
|     if (loadUsersResult.error) { | ||||
|       getAppEvents().publish({ | ||||
|         type: AppEvents.alertError.name, | ||||
|         payload: [ | ||||
|           t('query-library.user-info-get-error', 'Error attempting to get user info from the library: {{error}}', { | ||||
|             error: JSON.stringify(loadUsersResult.error), | ||||
|           }), | ||||
|         ], | ||||
|       }); | ||||
|     } | ||||
|   }, [loadUsersResult.error]); | ||||
|   return loadUsersResult; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Wrap useLoadQueryMetadata with error handling. | ||||
|  * @param queryTemplates | ||||
|  * @param userDataList | ||||
|  */ | ||||
| function useLoadQueryMetadataWithError( | ||||
|   queryTemplates: QueryTemplate[] | undefined, | ||||
|   userDataList: ReturnType<typeof useLoadUsers>['data'] | ||||
| ) { | ||||
|   const result = useLoadQueryMetadata(queryTemplates, userDataList); | ||||
| 
 | ||||
|   // useLoadQueryMetadata returns errors in the values so we filter and group them and later alert only one time for
 | ||||
|   // all the errors. This way we show data that is loaded even if some rows errored out.
 | ||||
|   // TODO: maybe we could show the rows with incomplete data to see exactly which ones errored out. I assume this
 | ||||
|   //  can happen for example when data source for saved query was deleted. Would be nice if user would still be able
 | ||||
|   //  to delete such row or decide what to do.
 | ||||
|   const [values, errors] = useMemo(() => { | ||||
|     let errors: Error[] = []; | ||||
|     let values = []; | ||||
|     if (!result.loading) { | ||||
|       for (const value of result.value!) { | ||||
|         if (value.error) { | ||||
|           errors.push(value.error); | ||||
|         } else { | ||||
|           values.push(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return [values, errors]; | ||||
|   }, [result]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (errors.length) { | ||||
|       getAppEvents().publish({ | ||||
|         type: AppEvents.alertError.name, | ||||
|         payload: [ | ||||
|           t('query-library.query-template-get-error', 'Error attempting to load query template metadata: {{error}}', { | ||||
|             error: JSON.stringify(errors), | ||||
|           }), | ||||
|         ], | ||||
|       }); | ||||
|     } | ||||
|   }, [errors]); | ||||
| 
 | ||||
|   return { | ||||
|     loading: result.loading, | ||||
|     value: values, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme2) => ({ | ||||
|   searchInput: css({ | ||||
|     maxWidth: theme.spacing(55), | ||||
|   }), | ||||
|   multiSelect: css({ | ||||
|     maxWidth: theme.spacing(65), | ||||
|   }), | ||||
|   label: css({ | ||||
|     marginLeft: theme.spacing(1), | ||||
|     border: `1px solid ${theme.colors.secondary.border}`, | ||||
|   }), | ||||
| }); | ||||
|  | @ -1,110 +0,0 @@ | |||
| import { useState } from 'react'; | ||||
| 
 | ||||
| import { getAppEvents } from '@grafana/runtime'; | ||||
| import { IconButton, Modal } from '@grafana/ui'; | ||||
| import { notifyApp } from 'app/core/actions'; | ||||
| import { createSuccessNotification } from 'app/core/copy/appNotification'; | ||||
| import { t } from 'app/core/internationalization'; | ||||
| import { useDeleteQueryTemplateMutation } from 'app/features/query-library'; | ||||
| import { dispatch } from 'app/store/store'; | ||||
| import { ShowConfirmModalEvent } from 'app/types/events'; | ||||
| 
 | ||||
| import { | ||||
|   queryLibaryTrackDeleteQuery, | ||||
|   queryLibraryTrackAddOrEditDescription, | ||||
|   queryLibraryTrackRunQuery, | ||||
| } from '../QueryLibraryAnalyticsEvents'; | ||||
| import { QueryTemplateForm } from '../QueryTemplateForm'; | ||||
| import { QueryActionButton } from '../types'; | ||||
| 
 | ||||
| import { useQueryLibraryListStyles } from './styles'; | ||||
| import { QueryTemplateRow } from './types'; | ||||
| 
 | ||||
| interface ActionsCellProps { | ||||
|   queryUid?: string; | ||||
|   queryTemplate: QueryTemplateRow; | ||||
|   rootDatasourceUid?: string; | ||||
|   QueryActionButton?: QueryActionButton; | ||||
| } | ||||
| 
 | ||||
| function ActionsCell({ queryTemplate, rootDatasourceUid, queryUid, QueryActionButton }: ActionsCellProps) { | ||||
|   const [deleteQueryTemplate] = useDeleteQueryTemplateMutation(); | ||||
|   const [editFormOpen, setEditFormOpen] = useState(false); | ||||
|   const styles = useQueryLibraryListStyles(); | ||||
| 
 | ||||
|   const onDeleteQuery = (queryUid: string) => { | ||||
|     const performDelete = (queryUid: string) => { | ||||
|       deleteQueryTemplate({ | ||||
|         name: queryUid, | ||||
|         deleteOptions: {}, | ||||
|       }); | ||||
|       dispatch(notifyApp(createSuccessNotification(t('explore.query-library.query-deleted', 'Query deleted')))); | ||||
|       queryLibaryTrackDeleteQuery(); | ||||
|     }; | ||||
| 
 | ||||
|     getAppEvents().publish( | ||||
|       new ShowConfirmModalEvent({ | ||||
|         title: t('explore.query-library.delete-query-title', 'Delete query'), | ||||
|         text: t( | ||||
|           'explore.query-library.delete-query-text', | ||||
|           "You're about to remove this query from the query library. This action cannot be undone. Do you want to continue?" | ||||
|         ), | ||||
|         yesText: t('query-library.delete-query-button', 'Delete query'), | ||||
|         icon: 'trash-alt', | ||||
|         onConfirm: () => performDelete(queryUid), | ||||
|       }) | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={styles.cell}> | ||||
|       <IconButton | ||||
|         className={styles.actionButton} | ||||
|         size="lg" | ||||
|         name="trash-alt" | ||||
|         title={t('explore.query-library.delete-query', 'Delete query')} | ||||
|         tooltip={t('explore.query-library.delete-query', 'Delete query')} | ||||
|         onClick={() => { | ||||
|           if (queryUid) { | ||||
|             onDeleteQuery(queryUid); | ||||
|           } | ||||
|         }} | ||||
|       /> | ||||
|       <IconButton | ||||
|         className={styles.actionButton} | ||||
|         size="lg" | ||||
|         name="comment-alt" | ||||
|         title={t('explore.query-library.add-edit-description', 'Add/edit description')} | ||||
|         tooltip={t('explore.query-library.add-edit-description', 'Add/edit description')} | ||||
|         onClick={() => { | ||||
|           setEditFormOpen(true); | ||||
|           queryLibraryTrackAddOrEditDescription(); | ||||
|         }} | ||||
|       /> | ||||
|       {QueryActionButton && ( | ||||
|         <QueryActionButton | ||||
|           queries={queryTemplate.query ? [queryTemplate.query] : []} | ||||
|           datasourceUid={rootDatasourceUid} | ||||
|           onClick={() => { | ||||
|             queryLibraryTrackRunQuery(queryTemplate.datasourceType || ''); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|       <Modal | ||||
|         title={t('explore.query-template-modal.edit-title', 'Edit query')} | ||||
|         isOpen={editFormOpen} | ||||
|         onDismiss={() => setEditFormOpen(false)} | ||||
|       > | ||||
|         <QueryTemplateForm | ||||
|           onCancel={() => setEditFormOpen(false)} | ||||
|           templateData={queryTemplate} | ||||
|           onSave={() => { | ||||
|             setEditFormOpen(false); | ||||
|           }} | ||||
|         /> | ||||
|       </Modal> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default ActionsCell; | ||||
|  | @ -1,20 +0,0 @@ | |||
| import { Avatar } from '@grafana/ui'; | ||||
| import { User } from 'app/features/query-library/types'; | ||||
| 
 | ||||
| import { useQueryLibraryListStyles } from './styles'; | ||||
| 
 | ||||
| type AddedByCellProps = { | ||||
|   user?: User; | ||||
| }; | ||||
| export function AddedByCell(props: AddedByCellProps) { | ||||
|   const styles = useQueryLibraryListStyles(); | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <span className={styles.logo}> | ||||
|         <Avatar src={props.user?.avatarUrl || 'https://secure.gravatar.com/avatar'} alt="unknown" /> | ||||
|       </span> | ||||
|       <span className={styles.otherText}>{props.user?.displayName || 'Unknown'}</span> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | @ -1,13 +0,0 @@ | |||
| import { CellProps } from 'react-table'; | ||||
| 
 | ||||
| import { useDatasource } from '../utils/useDatasource'; | ||||
| 
 | ||||
| import { useQueryLibraryListStyles } from './styles'; | ||||
| import { QueryTemplateRow } from './types'; | ||||
| 
 | ||||
| export function DatasourceTypeCell(props: CellProps<QueryTemplateRow>) { | ||||
|   const datasourceApi = useDatasource(props.row.original.datasourceRef); | ||||
|   const styles = useQueryLibraryListStyles(); | ||||
| 
 | ||||
|   return <p className={styles.otherText}>{datasourceApi?.meta.name}</p>; | ||||
| } | ||||
|  | @ -1,13 +0,0 @@ | |||
| import { CellProps } from 'react-table'; | ||||
| 
 | ||||
| import { dateTime } from '@grafana/data'; | ||||
| 
 | ||||
| import { useQueryLibraryListStyles } from './styles'; | ||||
| import { QueryTemplateRow } from './types'; | ||||
| 
 | ||||
| export function DateAddedCell(props: CellProps<QueryTemplateRow>) { | ||||
|   const styles = useQueryLibraryListStyles(); | ||||
|   const formattedTime = dateTime(props.row.original.createdAtTimestamp).format('YYYY-MM-DD HH:mm:ss'); | ||||
| 
 | ||||
|   return <p className={styles.otherText}>{formattedTime}</p>; | ||||
| } | ||||
|  | @ -1,55 +0,0 @@ | |||
| import { css, cx } from '@emotion/css'; | ||||
| import { CellProps } from 'react-table'; | ||||
| 
 | ||||
| import { GrafanaTheme2 } from '@grafana/data'; | ||||
| import { Spinner, Tooltip, useStyles2 } from '@grafana/ui'; | ||||
| 
 | ||||
| import { useDatasource } from '../utils/useDatasource'; | ||||
| 
 | ||||
| import { useQueryLibraryListStyles } from './styles'; | ||||
| import { QueryTemplateRow } from './types'; | ||||
| 
 | ||||
| export function QueryDescriptionCell(props: CellProps<QueryTemplateRow>) { | ||||
|   const datasourceApi = useDatasource(props.row.original.datasourceRef); | ||||
|   const queryLibraryListStyles = useQueryLibraryListStyles(); | ||||
|   const styles = useStyles2(getStyles); | ||||
| 
 | ||||
|   if (!datasourceApi) { | ||||
|     return <Spinner />; | ||||
|   } | ||||
| 
 | ||||
|   if (!props.row.original.query) { | ||||
|     return <div>No queries</div>; | ||||
|   } | ||||
|   const queryDisplayText = props.row.original.queryText; | ||||
|   const description = props.row.original.description; | ||||
|   const dsName = props.row.original.datasourceName; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={styles.container} aria-label={`Query template for ${dsName}: ${description}`}> | ||||
|       <p className={queryLibraryListStyles.header}> | ||||
|         <img | ||||
|           className={queryLibraryListStyles.logo} | ||||
|           src={datasourceApi?.meta.info.logos.small || 'public/img/icn-datasource.svg'} | ||||
|           alt={datasourceApi?.meta.info.description} | ||||
|         /> | ||||
|         {dsName} | ||||
|       </p> | ||||
|       <Tooltip content={queryDisplayText ?? ''} placement="bottom-start"> | ||||
|         <p className={cx(queryLibraryListStyles.mainText, queryLibraryListStyles.singleLine, styles.queryDisplayText)}> | ||||
|           {queryDisplayText} | ||||
|         </p> | ||||
|       </Tooltip> | ||||
|       <p className={cx(queryLibraryListStyles.otherText, queryLibraryListStyles.singleLine)}>{description}</p> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme2) => ({ | ||||
|   container: css({ | ||||
|     maxWidth: theme.spacing(60), | ||||
|   }), | ||||
|   queryDisplayText: css({ | ||||
|     backgroundColor: theme.colors.background.canvas, | ||||
|   }), | ||||
| }); | ||||
|  | @ -1,81 +0,0 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { SortByFn } from 'react-table'; | ||||
| 
 | ||||
| import { GrafanaTheme2 } from '@grafana/data'; | ||||
| import { Column, InteractiveTable, useStyles2 } from '@grafana/ui'; | ||||
| 
 | ||||
| import { QueryActionButton } from '../types'; | ||||
| 
 | ||||
| import ActionsCell from './ActionsCell'; | ||||
| import { AddedByCell } from './AddedByCell'; | ||||
| import { DatasourceTypeCell } from './DatasourceTypeCell'; | ||||
| import { DateAddedCell } from './DateAddedCell'; | ||||
| import { QueryDescriptionCell } from './QueryDescriptionCell'; | ||||
| import { QueryTemplateRow } from './types'; | ||||
| 
 | ||||
| const timestampSort: SortByFn<QueryTemplateRow> = (rowA, rowB, _, desc) => { | ||||
|   const timeA = rowA.original.createdAtTimestamp || 0; | ||||
|   const timeB = rowB.original.createdAtTimestamp || 0; | ||||
|   return desc ? timeA - timeB : timeB - timeA; | ||||
| }; | ||||
| 
 | ||||
| function createColumns(queryActionButton?: QueryActionButton): Array<Column<QueryTemplateRow>> { | ||||
|   return [ | ||||
|     { id: 'description', header: 'Data source and query', cell: QueryDescriptionCell }, | ||||
|     { id: 'addedBy', header: 'Added by', cell: ({ row: { original } }) => <AddedByCell user={original.user} /> }, | ||||
|     { id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' }, | ||||
|     { id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort }, | ||||
|     { | ||||
|       id: 'actions', | ||||
|       header: '', | ||||
|       cell: ({ row: { original } }) => ( | ||||
|         <ActionsCell | ||||
|           queryTemplate={original} | ||||
|           rootDatasourceUid={original.datasourceRef?.uid} | ||||
|           queryUid={original.uid} | ||||
|           QueryActionButton={queryActionButton} | ||||
|         /> | ||||
|       ), | ||||
|     }, | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| type Props = { | ||||
|   queryTemplateRows: QueryTemplateRow[]; | ||||
|   queryActionButton?: QueryActionButton; | ||||
| }; | ||||
| 
 | ||||
| export default function QueryTemplatesTable({ queryTemplateRows, queryActionButton }: Props) { | ||||
|   const styles = useStyles2(getStyles); | ||||
|   const columns = createColumns(queryActionButton); | ||||
| 
 | ||||
|   return ( | ||||
|     <InteractiveTable | ||||
|       columns={columns} | ||||
|       data={queryTemplateRows} | ||||
|       getRowId={(row: { index: string }) => row.index} | ||||
|       pageSize={20} | ||||
|       className={styles.table} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme2) => ({ | ||||
|   table: css({ | ||||
|     'tbody tr': { | ||||
|       position: 'relative', | ||||
|       backgroundColor: theme.colors.background.secondary, | ||||
|       borderCollapse: 'collapse', | ||||
|       borderBottom: 'unset', | ||||
|       overflow: 'hidden', // Ensure the row doesn't overflow and cause additonal scrollbars
 | ||||
|     }, | ||||
|     /* Adds the pseudo-element for the lines between table rows */ | ||||
|     'tbody tr::after': { | ||||
|       content: '""', | ||||
|       position: 'absolute', | ||||
|       inset: 'auto 0 0 0', | ||||
|       height: theme.spacing(0.5), | ||||
|       backgroundColor: theme.colors.background.primary, | ||||
|     }, | ||||
|   }), | ||||
| }); | ||||
|  | @ -1,47 +0,0 @@ | |||
| import { css } from '@emotion/css'; | ||||
| 
 | ||||
| import { GrafanaTheme2 } from '@grafana/data/'; | ||||
| import { useStyles2 } from '@grafana/ui/'; | ||||
| 
 | ||||
| export const useQueryLibraryListStyles = () => { | ||||
|   return useStyles2(getStyles); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme2) => ({ | ||||
|   logo: css({ | ||||
|     marginRight: theme.spacing(2), | ||||
|     width: '16px', | ||||
|   }), | ||||
|   header: css({ | ||||
|     margin: 0, | ||||
|     fontSize: theme.typography.h5.fontSize, | ||||
|     color: theme.colors.text.secondary, | ||||
|   }), | ||||
|   mainText: css({ | ||||
|     margin: 0, | ||||
|     fontSize: theme.typography.body.fontSize, | ||||
|     textOverflow: 'ellipsis', | ||||
|   }), | ||||
|   otherText: css({ | ||||
|     margin: 0, | ||||
|     fontSize: theme.typography.body.fontSize, | ||||
|     color: theme.colors.text.secondary, | ||||
|     textOverflow: 'ellipsis', | ||||
|   }), | ||||
|   singleLine: css({ | ||||
|     display: '-webkit-box', | ||||
|     WebkitBoxOrient: 'vertical', | ||||
|     WebkitLineClamp: 1, | ||||
|     overflow: 'hidden', | ||||
|   }), | ||||
|   cell: css({ | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     '&:last-child': { | ||||
|       justifyContent: 'end', | ||||
|     }, | ||||
|   }), | ||||
|   actionButton: css({ | ||||
|     padding: theme.spacing(1), | ||||
|   }), | ||||
| }); | ||||
|  | @ -1,15 +0,0 @@ | |||
| import { DataQuery, DataSourceRef } from '@grafana/schema'; | ||||
| import { User } from 'app/features/query-library/types'; | ||||
| 
 | ||||
| export type QueryTemplateRow = { | ||||
|   index: string; | ||||
|   datasourceName?: string; | ||||
|   description?: string; | ||||
|   query?: DataQuery; | ||||
|   queryText?: string; | ||||
|   datasourceRef?: DataSourceRef | null; | ||||
|   datasourceType?: string; | ||||
|   createdAtTimestamp?: number; | ||||
|   user?: User; | ||||
|   uid?: string; | ||||
| }; | ||||
|  | @ -1,44 +0,0 @@ | |||
| import { useLocalStorage } from 'react-use'; | ||||
| 
 | ||||
| import { DataQuery } from '@grafana/schema'; | ||||
| import { Badge } from '@grafana/ui'; | ||||
| 
 | ||||
| import { QueryOperationAction } from '../../../core/components/QueryOperationRow/QueryOperationAction'; | ||||
| import { t } from '../../../core/internationalization'; | ||||
| 
 | ||||
| import { QUERY_LIBRARY_LOCAL_STORAGE_KEYS } from './QueryLibrary'; | ||||
| import { useQueryLibraryContext } from './QueryLibraryContext'; | ||||
| 
 | ||||
| interface Props { | ||||
|   query: DataQuery; | ||||
| } | ||||
| 
 | ||||
| export function SaveQueryButton({ query }: Props) { | ||||
|   const { openAddQueryModal } = useQueryLibraryContext(); | ||||
| 
 | ||||
|   const [showQueryLibraryBadgeButton, setShowQueryLibraryBadgeButton] = useLocalStorage( | ||||
|     QUERY_LIBRARY_LOCAL_STORAGE_KEYS.explore.newButton, | ||||
|     true | ||||
|   ); | ||||
| 
 | ||||
|   return showQueryLibraryBadgeButton ? ( | ||||
|     <Badge | ||||
|       text={t('query-operation.header.save-to-query-library-new', 'New: Save to query library')} | ||||
|       icon="save" | ||||
|       color="blue" | ||||
|       onClick={() => { | ||||
|         openAddQueryModal(query); | ||||
|         setShowQueryLibraryBadgeButton(false); | ||||
|       }} | ||||
|       style={{ cursor: 'pointer' }} | ||||
|     /> | ||||
|   ) : ( | ||||
|     <QueryOperationAction | ||||
|       title={t('query-operation.header.save-to-query-library', 'Save to query library')} | ||||
|       icon="save" | ||||
|       onClick={() => { | ||||
|         openAddQueryModal(query); | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | @ -1,106 +0,0 @@ | |||
| import { skipToken } from '@reduxjs/toolkit/query'; | ||||
| import { compact, uniq } from 'lodash'; | ||||
| import { useAsync } from 'react-use'; | ||||
| import { AsyncState } from 'react-use/lib/useAsync'; | ||||
| 
 | ||||
| import { getDataSourceSrv } from '@grafana/runtime'; | ||||
| import { DataQuery, DataSourceRef } from '@grafana/schema'; | ||||
| 
 | ||||
| import { createQueryText } from '../../../../core/utils/richHistory'; | ||||
| import { useGetDisplayMappingQuery } from '../../../iam'; | ||||
| import { getDatasourceSrv } from '../../../plugins/datasource_srv'; | ||||
| import { QueryTemplate } from '../../../query-library/types'; | ||||
| 
 | ||||
| export function useLoadUsers(userUIDs: string[] | undefined) { | ||||
|   const userQtList = uniq(compact(userUIDs)); | ||||
|   return useGetDisplayMappingQuery( | ||||
|     userUIDs | ||||
|       ? { | ||||
|           key: userQtList, | ||||
|         } | ||||
|       : skipToken | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| // Explicitly type the result so TS knows to discriminate between the error result and good result by the error prop
 | ||||
| // value.
 | ||||
| type MetadataValue = | ||||
|   | { | ||||
|       index: string; | ||||
|       uid: string; | ||||
|       datasourceName: string; | ||||
|       datasourceRef: DataSourceRef | undefined | null; | ||||
|       datasourceType: string; | ||||
|       createdAtTimestamp: number; | ||||
|       query: DataQuery; | ||||
|       queryText: string; | ||||
|       description: string; | ||||
|       user: { | ||||
|         uid: string; | ||||
|         displayName: string; | ||||
|         avatarUrl: string; | ||||
|       }; | ||||
|       error: undefined; | ||||
|     } | ||||
|   | { | ||||
|       index: string; | ||||
|       error: Error; | ||||
|     }; | ||||
| 
 | ||||
| /** | ||||
|  * Map metadata to query templates we get from the DB. | ||||
|  * @param queryTemplates | ||||
|  * @param userDataList | ||||
|  */ | ||||
| export function useLoadQueryMetadata( | ||||
|   queryTemplates: QueryTemplate[] | undefined, | ||||
|   userDataList: ReturnType<typeof useLoadUsers>['data'] | ||||
| ): AsyncState<MetadataValue[]> { | ||||
|   return useAsync(async () => { | ||||
|     if (!(queryTemplates && userDataList)) { | ||||
|       return []; | ||||
|     } | ||||
| 
 | ||||
|     const rowsPromises = queryTemplates.map( | ||||
|       async (queryTemplate: QueryTemplate, index: number): Promise<MetadataValue> => { | ||||
|         try { | ||||
|           const datasourceRef = queryTemplate.targets[0]?.datasource; | ||||
|           const datasourceApi = await getDataSourceSrv().get(datasourceRef); | ||||
|           const datasourceType = getDatasourceSrv().getInstanceSettings(datasourceRef)?.meta.name || ''; | ||||
|           const query = queryTemplate.targets[0]; | ||||
|           const queryText = createQueryText(query, datasourceApi); | ||||
|           const datasourceName = datasourceApi?.name || ''; | ||||
|           const extendedUserData = userDataList.display.find( | ||||
|             (user) => `${user?.identity.type}:${user?.identity.name}` === queryTemplate.user?.uid | ||||
|           ); | ||||
| 
 | ||||
|           return { | ||||
|             index: index.toString(), | ||||
|             uid: queryTemplate.uid, | ||||
|             datasourceName, | ||||
|             datasourceRef, | ||||
|             datasourceType, | ||||
|             createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0, | ||||
|             query, | ||||
|             queryText, | ||||
|             description: queryTemplate.title, | ||||
|             user: { | ||||
|               uid: queryTemplate.user?.uid || '', | ||||
|               displayName: extendedUserData?.displayName || '', | ||||
|               avatarUrl: extendedUserData?.avatarURL || '', | ||||
|             }, | ||||
|             error: undefined, | ||||
|           }; | ||||
|         } catch (error) { | ||||
|           // Instead of throwing we collect the errors in the result so upstream code can decide what to do.
 | ||||
|           return { | ||||
|             index: index.toString(), | ||||
|             error: error instanceof Error ? error : new Error('unknown error ' + JSON.stringify(error)), | ||||
|           }; | ||||
|         } | ||||
|       } | ||||
|     ); | ||||
| 
 | ||||
|     return Promise.all(rowsPromises); | ||||
|   }, [queryTemplates, userDataList]); | ||||
| } | ||||
|  | @ -1,24 +0,0 @@ | |||
| import { QueryTemplateRow } from '../QueryTemplatesTable/types'; | ||||
| 
 | ||||
| export const searchQueryLibrary = ( | ||||
|   queryLibrary: QueryTemplateRow[], | ||||
|   query: string, | ||||
|   dsFilters: string[], | ||||
|   userNameFilters: string[] | ||||
| ) => { | ||||
|   const result = queryLibrary.filter((item) => { | ||||
|     const matchesDsFilter = | ||||
|       dsFilters.length === 0 || dsFilters.some((f) => item.datasourceName?.toLowerCase().includes(f.toLowerCase())); | ||||
|     const matchesUserNameFilter = | ||||
|       userNameFilters.length === 0 || userNameFilters.includes(item.user?.displayName || ''); | ||||
|     return ( | ||||
|       (item.datasourceName?.toLowerCase().includes(query.toLowerCase()) || | ||||
|         item.datasourceType?.toLowerCase().includes(query.toLowerCase()) || | ||||
|         item.description?.toLowerCase().includes(query.toLowerCase()) || | ||||
|         item.queryText?.toLowerCase().includes(query.toLowerCase())) && | ||||
|       matchesDsFilter && | ||||
|       matchesUserNameFilter | ||||
|     ); | ||||
|   }); | ||||
|   return result; | ||||
| }; | ||||
|  | @ -1,9 +0,0 @@ | |||
| import { useAsync } from 'react-use'; | ||||
| 
 | ||||
| import { getDataSourceSrv } from '@grafana/runtime'; | ||||
| import { DataSourceRef } from '@grafana/schema'; | ||||
| 
 | ||||
| export function useDatasource(dataSourceRef?: DataSourceRef | null) { | ||||
|   const { value } = useAsync(async () => await getDataSourceSrv().get(dataSourceRef), [dataSourceRef]); | ||||
|   return value; | ||||
| } | ||||
|  | @ -1,57 +1,32 @@ | |||
| import { useState } from 'react'; | ||||
| 
 | ||||
| import { DataQuery } from '@grafana/schema'; | ||||
| import { Button, Modal } from '@grafana/ui'; | ||||
| import { Button } from '@grafana/ui'; | ||||
| import { t } from 'app/core/internationalization'; | ||||
| import { isQueryLibraryEnabled, useListQueryTemplateQuery } from 'app/features/query-library'; | ||||
| 
 | ||||
| import { | ||||
|   queryLibraryTrackAddFromQueryHistory, | ||||
|   queryLibraryTrackAddFromQueryHistoryAddModalShown, | ||||
| } from '../QueryLibrary/QueryLibraryAnalyticsEvents'; | ||||
| import { QueryTemplateForm } from '../QueryLibrary/QueryTemplateForm'; | ||||
| import { useQueryLibraryContext } from '../QueryLibrary/QueryLibraryContext'; | ||||
| 
 | ||||
| type Props = { | ||||
|   query: DataQuery; | ||||
| }; | ||||
| 
 | ||||
| export const RichHistoryAddToLibrary = ({ query }: Props) => { | ||||
|   const { refetch } = useListQueryTemplateQuery({}); | ||||
|   const [isOpen, setIsOpen] = useState(false); | ||||
|   const [hasBeenSaved, setHasBeenSaved] = useState(false); | ||||
|   const { openAddQueryModal, queryLibraryEnabled } = useQueryLibraryContext(); | ||||
| 
 | ||||
|   const buttonLabel = t('explore.rich-history-card.add-to-library', 'Add to library'); | ||||
| 
 | ||||
|   return isQueryLibraryEnabled() && !hasBeenSaved ? ( | ||||
|   return queryLibraryEnabled && !hasBeenSaved ? ( | ||||
|     <> | ||||
|       <Button | ||||
|         variant="secondary" | ||||
|         aria-label={buttonLabel} | ||||
|         onClick={() => { | ||||
|           setIsOpen(true); | ||||
|           queryLibraryTrackAddFromQueryHistoryAddModalShown(); | ||||
|           openAddQueryModal(query, { onSave: () => setHasBeenSaved(true), context: 'richHistory' }); | ||||
|         }} | ||||
|       > | ||||
|         {buttonLabel} | ||||
|       </Button> | ||||
|       <Modal | ||||
|         title={t('explore.query-template-modal.add-title', 'Add query to Query Library')} | ||||
|         isOpen={isOpen} | ||||
|         onDismiss={() => setIsOpen(false)} | ||||
|       > | ||||
|         <QueryTemplateForm | ||||
|           onCancel={() => setIsOpen(() => false)} | ||||
|           queryToAdd={query} | ||||
|           onSave={(isSuccess) => { | ||||
|             if (isSuccess) { | ||||
|               setIsOpen(false); | ||||
|               setHasBeenSaved(true); | ||||
|               refetch(); | ||||
|               queryLibraryTrackAddFromQueryHistory(query.datasource?.type || ''); | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       </Modal> | ||||
|     </> | ||||
|   ) : undefined; | ||||
| }; | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ import { createMemoryHistory } from 'history'; | |||
| import { KBarProvider } from 'kbar'; | ||||
| import { fromPairs } from 'lodash'; | ||||
| import { stringify } from 'querystring'; | ||||
| import { ComponentType, ReactNode } from 'react'; | ||||
| import { Provider } from 'react-redux'; | ||||
| // eslint-disable-next-line no-restricted-imports
 | ||||
| import { Route, Router } from 'react-router-dom'; | ||||
|  | @ -47,7 +48,6 @@ import { ExploreQueryParams } from '../../../../types'; | |||
| import { initialUserState } from '../../../profile/state/reducers'; | ||||
| import ExplorePage from '../../ExplorePage'; | ||||
| import { QueriesDrawerContextProvider } from '../../QueriesDrawer/QueriesDrawerContext'; | ||||
| import { QueryLibraryContextProvider } from '../../QueryLibrary/QueryLibraryContext'; | ||||
| 
 | ||||
| type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi }; | ||||
| 
 | ||||
|  | @ -60,6 +60,7 @@ type SetupOptions = { | |||
|   failAddToLibrary?: boolean; | ||||
|   // Use AppChrome wrapper around ExplorePage - needed to test query library/history
 | ||||
|   withAppChrome?: boolean; | ||||
|   provider?: ComponentType<{ children: ReactNode }>; | ||||
| }; | ||||
| 
 | ||||
| type TearDownOptions = { | ||||
|  | @ -179,12 +180,18 @@ export function setupExplore(options?: SetupOptions): { | |||
| 
 | ||||
|   const contextMock = getGrafanaContextMock({ location }); | ||||
| 
 | ||||
|   const FinalProvider = | ||||
|     options?.provider || | ||||
|     (({ children }) => { | ||||
|       return children; | ||||
|     }); | ||||
| 
 | ||||
|   const { unmount, container } = render( | ||||
|     <Provider store={storeState}> | ||||
|       <GrafanaContext.Provider value={contextMock}> | ||||
|         <Router history={history}> | ||||
|           <QueryLibraryContextProvider> | ||||
|           <QueriesDrawerContextProvider> | ||||
|             <FinalProvider> | ||||
|               {options?.withAppChrome ? ( | ||||
|                 <KBarProvider> | ||||
|                   <AppChrome> | ||||
|  | @ -204,8 +211,8 @@ export function setupExplore(options?: SetupOptions): { | |||
|                   render={(props) => <GrafanaRoute {...props} route={{ component: ExplorePage, path: '/explore' }} />} | ||||
|                 /> | ||||
|               )} | ||||
|             </FinalProvider> | ||||
|           </QueriesDrawerContextProvider> | ||||
|           </QueryLibraryContextProvider> | ||||
|         </Router> | ||||
|       </GrafanaContext.Provider> | ||||
|     </Provider> | ||||
|  |  | |||
|  | @ -1,172 +0,0 @@ | |||
| import { Props } from 'react-virtualized-auto-sizer'; | ||||
| 
 | ||||
| import { EventBusSrv } from '@grafana/data'; | ||||
| import { config } from '@grafana/runtime'; | ||||
| import { DataQuery } from '@grafana/schema/dist/esm/veneer/common.types'; | ||||
| 
 | ||||
| import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; | ||||
| 
 | ||||
| import { | ||||
|   assertAddToQueryLibraryButtonExists, | ||||
|   assertQueryHistory, | ||||
|   assertQueryLibraryTemplateExists, | ||||
| } from './helper/assert'; | ||||
| import { | ||||
|   addQueryHistoryToQueryLibrary, | ||||
|   openQueryHistory, | ||||
|   openQueryLibrary, | ||||
|   submitAddToQueryLibrary, | ||||
| } from './helper/interactions'; | ||||
| import { setupExplore, waitForExplore } from './helper/setup'; | ||||
| 
 | ||||
| const reportInteractionMock = jest.fn(); | ||||
| const testEventBus = new EventBusSrv(); | ||||
| testEventBus.publish = jest.fn(); | ||||
| 
 | ||||
| interface MockQuery extends DataQuery { | ||||
|   expr: string; | ||||
| } | ||||
| 
 | ||||
| jest.mock('../QueryLibrary/utils/dataFetching', () => { | ||||
|   return { | ||||
|     __esModule: true, | ||||
|     ...jest.requireActual('../QueryLibrary/utils/dataFetching'), | ||||
|     useLoadUsers: () => { | ||||
|       return { | ||||
|         data: { | ||||
|           display: [ | ||||
|             { | ||||
|               avatarUrl: '', | ||||
|               displayName: 'john doe', | ||||
|               identity: { | ||||
|                 name: 'JohnDoe', | ||||
|                 type: 'viewer', | ||||
|               }, | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|         isLoading: false, | ||||
|         error: null, | ||||
|       }; | ||||
|     }, | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| jest.mock('@grafana/runtime', () => ({ | ||||
|   ...jest.requireActual('@grafana/runtime'), | ||||
|   reportInteraction: (...args: object[]) => { | ||||
|     reportInteractionMock(...args); | ||||
|   }, | ||||
|   getAppEvents: () => testEventBus, | ||||
|   usePluginLinks: jest.fn().mockReturnValue({ links: [] }), | ||||
| })); | ||||
| 
 | ||||
| jest.mock('app/core/core', () => ({ | ||||
|   contextSrv: { | ||||
|     hasPermission: () => true, | ||||
|     isSignedIn: true, | ||||
|     getValidIntervals: (defaultIntervals: string[]) => defaultIntervals, | ||||
|     user: { | ||||
|       isSignedIn: true, | ||||
|     }, | ||||
|   }, | ||||
| })); | ||||
| 
 | ||||
| jest.mock('app/core/services/PreferencesService', () => ({ | ||||
|   PreferencesService: function () { | ||||
|     return { | ||||
|       patch: jest.fn(), | ||||
|       load: jest.fn().mockResolvedValue({ | ||||
|         queryHistory: { | ||||
|           homeTab: 'query', | ||||
|         }, | ||||
|       }), | ||||
|     }; | ||||
|   }, | ||||
| })); | ||||
| 
 | ||||
| jest.mock('../hooks/useExplorePageTitle', () => ({ | ||||
|   useExplorePageTitle: jest.fn(), | ||||
| })); | ||||
| 
 | ||||
| jest.mock('react-virtualized-auto-sizer', () => { | ||||
|   return { | ||||
|     __esModule: true, | ||||
|     default(props: Props) { | ||||
|       return <div>{props.children({ height: 1, scaledHeight: 1, scaledWidth: 1000, width: 1000 })}</div>; | ||||
|     }, | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| function setupQueryLibrary() { | ||||
|   const mockQuery: MockQuery = { refId: 'TEST', expr: 'TEST' }; | ||||
|   setupExplore({ | ||||
|     queryHistory: { | ||||
|       queryHistory: [{ datasourceUid: 'loki', queries: [mockQuery] }], | ||||
|       totalCount: 1, | ||||
|     }, | ||||
|     withAppChrome: true, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| let previousQueryLibraryEnabled: boolean | undefined; | ||||
| let previousQueryHistoryEnabled: boolean; | ||||
| 
 | ||||
| describe('QueryLibrary', () => { | ||||
|   silenceConsoleOutput(); | ||||
| 
 | ||||
|   beforeAll(() => { | ||||
|     previousQueryLibraryEnabled = config.featureToggles.queryLibrary; | ||||
|     previousQueryHistoryEnabled = config.queryHistoryEnabled; | ||||
| 
 | ||||
|     config.featureToggles.queryLibrary = true; | ||||
|     config.queryHistoryEnabled = true; | ||||
|   }); | ||||
| 
 | ||||
|   afterAll(() => { | ||||
|     config.featureToggles.queryLibrary = previousQueryLibraryEnabled; | ||||
|     config.queryHistoryEnabled = previousQueryHistoryEnabled; | ||||
|     jest.restoreAllMocks(); | ||||
|   }); | ||||
| 
 | ||||
|   it('Load query templates', async () => { | ||||
|     setupQueryLibrary(); | ||||
|     await waitForExplore(); | ||||
|     await openQueryLibrary(); | ||||
|     await assertQueryLibraryTemplateExists('loki', 'Loki Query Template'); | ||||
|   }); | ||||
| 
 | ||||
|   it('Shows add to query library button only when the toggle is enabled', async () => { | ||||
|     setupQueryLibrary(); | ||||
|     await waitForExplore(); | ||||
|     await openQueryHistory(); | ||||
|     await assertQueryHistory(['{"expr":"TEST"}']); | ||||
|     await assertAddToQueryLibraryButtonExists(true); | ||||
|   }); | ||||
| 
 | ||||
|   it('Does not show the query library button when the toggle is disabled', async () => { | ||||
|     config.featureToggles.queryLibrary = false; | ||||
|     setupQueryLibrary(); | ||||
|     await waitForExplore(); | ||||
|     await openQueryHistory(); | ||||
|     await assertQueryHistory(['{"expr":"TEST"}']); | ||||
|     await assertAddToQueryLibraryButtonExists(false); | ||||
|     config.featureToggles.queryLibrary = true; | ||||
|   }); | ||||
| 
 | ||||
|   it('Shows a notification when a template is added and hides the add button', async () => { | ||||
|     setupQueryLibrary(); | ||||
|     await waitForExplore(); | ||||
|     await openQueryHistory(); | ||||
|     await assertQueryHistory(['{"expr":"TEST"}']); | ||||
|     await addQueryHistoryToQueryLibrary(); | ||||
|     await submitAddToQueryLibrary({ description: 'Test' }); | ||||
|     expect(testEventBus.publish).toHaveBeenCalledWith( | ||||
|       expect.objectContaining({ | ||||
|         type: 'alert-success', | ||||
|         payload: ['Query successfully saved to the library'], | ||||
|       }) | ||||
|     ); | ||||
|     await assertAddToQueryLibraryButtonExists(false); | ||||
|   }); | ||||
| }); | ||||
|  | @ -7,8 +7,6 @@ | |||
|  * @alpha | ||||
|  */ | ||||
| 
 | ||||
| import { config } from '@grafana/runtime'; | ||||
| 
 | ||||
| import { QUERY_LIBRARY_GET_LIMIT } from './api/api'; | ||||
| import { generatedQueryLibraryApi } from './api/endpoints.gen'; | ||||
| import { mockData } from './api/mocks'; | ||||
|  | @ -46,10 +44,6 @@ export const { | |||
|   }, | ||||
| }); | ||||
| 
 | ||||
| export function isQueryLibraryEnabled() { | ||||
|   return config.featureToggles.queryLibrary; | ||||
| } | ||||
| 
 | ||||
| export const QueryLibraryMocks = { | ||||
|   data: mockData.all, | ||||
| }; | ||||
|  |  | |||
|  | @ -8,7 +8,6 @@ import { PureComponent, ReactNode } from 'react'; | |||
| // Utils & Services
 | ||||
| import { | ||||
|   CoreApp, | ||||
|   DataQuery, | ||||
|   DataSourceApi, | ||||
|   DataSourceInstanceSettings, | ||||
|   DataSourcePluginContextProvider, | ||||
|  | @ -24,7 +23,8 @@ import { | |||
|   toLegacyResponseData, | ||||
| } from '@grafana/data'; | ||||
| import { selectors } from '@grafana/e2e-selectors'; | ||||
| import { AngularComponent, config, getAngularLoader, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; | ||||
| import { AngularComponent, getAngularLoader, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; | ||||
| import { DataQuery } from '@grafana/schema'; | ||||
| import { Badge, ErrorBoundaryAlert } from '@grafana/ui'; | ||||
| import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp'; | ||||
| import { | ||||
|  | @ -40,7 +40,7 @@ import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; | |||
| import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; | ||||
| import { PanelModel } from 'app/features/dashboard/state/PanelModel'; | ||||
| 
 | ||||
| import { SaveQueryButton as SaveQueryToQueryLibraryButton } from '../../explore/QueryLibrary/SaveQueryButton'; | ||||
| import { useQueryLibraryContext } from '../../explore/QueryLibrary/QueryLibraryContext'; | ||||
| 
 | ||||
| import { QueryActionComponent, RowActionComponents } from './QueryActionComponent'; | ||||
| import { QueryEditorRowHeader } from './QueryEditorRowHeader'; | ||||
|  | @ -489,7 +489,7 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop | |||
|           /> | ||||
|         )} | ||||
|         {this.renderExtraActions()} | ||||
|         {config.featureToggles.queryLibrary && <SaveQueryToQueryLibraryButton query={query} />} | ||||
|         <MaybeQueryLibrarySaveButton query={query} /> | ||||
|         <QueryOperationAction | ||||
|           title={t('query-operation.header.duplicate-query', 'Duplicate query')} | ||||
|           icon="copy" | ||||
|  | @ -659,3 +659,9 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat | |||
|     timeRange, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| // Will render anything only if query library is enabled
 | ||||
| function MaybeQueryLibrarySaveButton(props: { query: DataQuery }) { | ||||
|   const { renderSaveQueryButton } = useQueryLibraryContext(); | ||||
|   return renderSaveQueryButton(props.query); | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { ComponentType } from 'react'; | ||||
| import { ComponentType, ReactNode } from 'react'; | ||||
| // eslint-disable-next-line no-restricted-imports
 | ||||
| import { Router } from 'react-router-dom'; | ||||
| import { CompatRouter } from 'react-router-dom-v5-compat'; | ||||
|  | @ -18,12 +18,18 @@ import { AppChrome } from '../core/components/AppChrome/AppChrome'; | |||
| import { AppNotificationList } from '../core/components/AppNotifications/AppNotificationList'; | ||||
| import { ModalsContextProvider } from '../core/context/ModalsContextProvider'; | ||||
| import { QueriesDrawerContextProvider } from '../features/explore/QueriesDrawer/QueriesDrawerContext'; | ||||
| import { QueryLibraryContextProvider } from '../features/explore/QueryLibrary/QueryLibraryContext'; | ||||
| 
 | ||||
| function ExtraProviders(props: { children: ReactNode; providers: Array<ComponentType<{ children: ReactNode }>> }) { | ||||
|   return props.providers.reduce((tree, Provider): ReactNode => { | ||||
|     return <Provider>{tree}</Provider>; | ||||
|   }, props.children); | ||||
| } | ||||
| 
 | ||||
| type RouterWrapperProps = { | ||||
|   routes?: JSX.Element | false; | ||||
|   bodyRenderHooks: ComponentType[]; | ||||
|   pageBanners: ComponentType[]; | ||||
|   providers: Array<ComponentType<{ children: ReactNode }>>; | ||||
| }; | ||||
| export function RouterWrapper(props: RouterWrapperProps) { | ||||
|   return ( | ||||
|  | @ -31,7 +37,7 @@ export function RouterWrapper(props: RouterWrapperProps) { | |||
|       <LocationServiceProvider service={locationService}> | ||||
|         <CompatRouter> | ||||
|           <QueriesDrawerContextProvider> | ||||
|             <QueryLibraryContextProvider> | ||||
|             <ExtraProviders providers={props.providers}> | ||||
|               <ModalsContextProvider> | ||||
|                 <AppChrome> | ||||
|                   <AngularRoot /> | ||||
|  | @ -48,7 +54,7 @@ export function RouterWrapper(props: RouterWrapperProps) { | |||
|                 </AppChrome> | ||||
|                 <ModalRoot /> | ||||
|               </ModalsContextProvider> | ||||
|             </QueryLibraryContextProvider> | ||||
|             </ExtraProviders> | ||||
|           </QueriesDrawerContextProvider> | ||||
|         </CompatRouter> | ||||
|       </LocationServiceProvider> | ||||
|  |  | |||
|  | @ -1346,36 +1346,6 @@ | |||
|       "scan-for-older-logs": "Scan for older logs", | ||||
|       "stop-scan": "Stop scan" | ||||
|     }, | ||||
|     "query-library": { | ||||
|       "add-edit-description": "Add/edit description", | ||||
|       "cancel": "Cancel", | ||||
|       "default-description": "Public", | ||||
|       "delete-query": "Delete query", | ||||
|       "delete-query-text": "You're about to remove this query from the query library. This action cannot be undone. Do you want to continue?", | ||||
|       "delete-query-title": "Delete query", | ||||
|       "private": "Private", | ||||
|       "public": "Public", | ||||
|       "query-deleted": "Query deleted", | ||||
|       "query-template-add-error": "Error attempting to save this query to the library", | ||||
|       "query-template-added": "Query successfully saved to the library", | ||||
|       "query-template-edit-error": "Error attempting to edit this query", | ||||
|       "query-template-edited": "Query template successfully edited", | ||||
|       "save": "Save" | ||||
|     }, | ||||
|     "query-template-modal": { | ||||
|       "add-info": "You're about to save this query. Once saved, you can easily access it in the Query Library tab for future use and reference.", | ||||
|       "add-title": "Add query to Query Library", | ||||
|       "auto-star": "Auto-star this query to add it to your starred list in the Query Library.", | ||||
|       "data-source-name": "Data source name", | ||||
|       "description": "Description", | ||||
|       "edit-info": "You're about to edit this query. Once saved, you can easily access it in the Query Library tab for future use and reference.", | ||||
|       "edit-title": "Edit query", | ||||
|       "query": "Query", | ||||
|       "visibility": "Visibility" | ||||
|     }, | ||||
|     "query-template-modall": { | ||||
|       "data-source-type": "Data source type" | ||||
|     }, | ||||
|     "rich-history": { | ||||
|       "close-tooltip": "Close query history", | ||||
|       "datasource-a-z": "Data source A-Z", | ||||
|  | @ -2993,14 +2963,6 @@ | |||
|       "role-label": "Role" | ||||
|     } | ||||
|   }, | ||||
|   "query-library": { | ||||
|     "datasource-names": "Datasource name(s):", | ||||
|     "delete-query-button": "Delete query", | ||||
|     "query-template-get-error": "Error attempting to load query template metadata: {{error}}", | ||||
|     "search": "Search by data source, query content or description", | ||||
|     "user-info-get-error": "Error attempting to get user info from the library: {{error}}", | ||||
|     "user-names": "User name(s):" | ||||
|   }, | ||||
|   "query-operation": { | ||||
|     "header": { | ||||
|       "collapse-row": "Collapse query row", | ||||
|  | @ -3010,8 +2972,6 @@ | |||
|       "expand-row": "Expand query row", | ||||
|       "hide-response": "Hide response", | ||||
|       "remove-query": "Remove query", | ||||
|       "save-to-query-library": "Save to query library", | ||||
|       "save-to-query-library-new": "New: Save to query library", | ||||
|       "show-response": "Show response", | ||||
|       "toggle-edit-mode": "Toggle text edit mode" | ||||
|     }, | ||||
|  |  | |||
|  | @ -1346,36 +1346,6 @@ | |||
|       "scan-for-older-logs": "Ŝčäʼn ƒőř őľđęř ľőģş", | ||||
|       "stop-scan": "Ŝŧőp şčäʼn" | ||||
|     }, | ||||
|     "query-library": { | ||||
|       "add-edit-description": "Åđđ/ęđįŧ đęşčřįpŧįőʼn", | ||||
|       "cancel": "Cäʼnčęľ", | ||||
|       "default-description": "Pūþľįč", | ||||
|       "delete-query": "Đęľęŧę qūęřy", | ||||
|       "delete-query-text": "Ÿőū'řę äþőūŧ ŧő řęmővę ŧĥįş qūęřy ƒřőm ŧĥę qūęřy ľįþřäřy. Ŧĥįş äčŧįőʼn čäʼnʼnőŧ þę ūʼnđőʼnę. Đő yőū ŵäʼnŧ ŧő čőʼnŧįʼnūę?", | ||||
|       "delete-query-title": "Đęľęŧę qūęřy", | ||||
|       "private": "Přįväŧę", | ||||
|       "public": "Pūþľįč", | ||||
|       "query-deleted": "Qūęřy đęľęŧęđ", | ||||
|       "query-template-add-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő şävę ŧĥįş qūęřy ŧő ŧĥę ľįþřäřy", | ||||
|       "query-template-added": "Qūęřy şūččęşşƒūľľy şävęđ ŧő ŧĥę ľįþřäřy", | ||||
|       "query-template-edit-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ęđįŧ ŧĥįş qūęřy", | ||||
|       "query-template-edited": "Qūęřy ŧęmpľäŧę şūččęşşƒūľľy ęđįŧęđ", | ||||
|       "save": "Ŝävę" | ||||
|     }, | ||||
|     "query-template-modal": { | ||||
|       "add-info": "Ÿőū'řę äþőūŧ ŧő şävę ŧĥįş qūęřy. Øʼnčę şävęđ, yőū čäʼn ęäşįľy äččęşş įŧ įʼn ŧĥę Qūęřy Ŀįþřäřy ŧäþ ƒőř ƒūŧūřę ūşę äʼnđ řęƒęřęʼnčę.", | ||||
|       "add-title": "Åđđ qūęřy ŧő Qūęřy Ŀįþřäřy", | ||||
|       "auto-star": "Åūŧő-şŧäř ŧĥįş qūęřy ŧő äđđ įŧ ŧő yőūř şŧäřřęđ ľįşŧ įʼn ŧĥę Qūęřy Ŀįþřäřy.", | ||||
|       "data-source-name": "Đäŧä şőūřčę ʼnämę", | ||||
|       "description": "Đęşčřįpŧįőʼn", | ||||
|       "edit-info": "Ÿőū'řę äþőūŧ ŧő ęđįŧ ŧĥįş qūęřy. Øʼnčę şävęđ, yőū čäʼn ęäşįľy äččęşş įŧ įʼn ŧĥę Qūęřy Ŀįþřäřy ŧäþ ƒőř ƒūŧūřę ūşę äʼnđ řęƒęřęʼnčę.", | ||||
|       "edit-title": "Ēđįŧ qūęřy", | ||||
|       "query": "Qūęřy", | ||||
|       "visibility": "Vįşįþįľįŧy" | ||||
|     }, | ||||
|     "query-template-modall": { | ||||
|       "data-source-type": "Đäŧä şőūřčę ŧypę" | ||||
|     }, | ||||
|     "rich-history": { | ||||
|       "close-tooltip": "Cľőşę qūęřy ĥįşŧőřy", | ||||
|       "datasource-a-z": "Đäŧä şőūřčę Å-Ż", | ||||
|  | @ -2993,14 +2963,6 @@ | |||
|       "role-label": "Ŗőľę" | ||||
|     } | ||||
|   }, | ||||
|   "query-library": { | ||||
|     "datasource-names": "Đäŧäşőūřčę ʼnämę(ş):", | ||||
|     "delete-query-button": "Đęľęŧę qūęřy", | ||||
|     "query-template-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ľőäđ qūęřy ŧęmpľäŧę męŧäđäŧä: {{error}}", | ||||
|     "search": "Ŝęäřčĥ þy đäŧä şőūřčę, qūęřy čőʼnŧęʼnŧ őř đęşčřįpŧįőʼn", | ||||
|     "user-info-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ģęŧ ūşęř įʼnƒő ƒřőm ŧĥę ľįþřäřy: {{error}}", | ||||
|     "user-names": "Ůşęř ʼnämę(ş):" | ||||
|   }, | ||||
|   "query-operation": { | ||||
|     "header": { | ||||
|       "collapse-row": "Cőľľäpşę qūęřy řőŵ", | ||||
|  | @ -3010,8 +2972,6 @@ | |||
|       "expand-row": "Ēχpäʼnđ qūęřy řőŵ", | ||||
|       "hide-response": "Ħįđę řęşpőʼnşę", | ||||
|       "remove-query": "Ŗęmővę qūęřy", | ||||
|       "save-to-query-library": "Ŝävę ŧő qūęřy ľįþřäřy", | ||||
|       "save-to-query-library-new": "Ńęŵ: Ŝävę ŧő qūęřy ľįþřäřy", | ||||
|       "show-response": "Ŝĥőŵ řęşpőʼnşę", | ||||
|       "toggle-edit-mode": "Ŧőģģľę ŧęχŧ ęđįŧ mőđę" | ||||
|     }, | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue