import { css, cx } from '@emotion/css'; import React, { useCallback, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { useAsync } from 'react-use'; import { GrafanaTheme2, DataSourceApi } from '@grafana/data'; import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { TextArea, Button, IconButton, useStyles2, LoadingPlaceholder } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import appEvents from 'app/core/app_events'; import { createSuccessNotification } from 'app/core/copy/appNotification'; import { copyStringToClipboard } from 'app/core/utils/explore'; import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { changeDatasource } from 'app/features/explore/state/datasource'; import { starHistoryItem, commentHistoryItem, deleteHistoryItem } from 'app/features/explore/state/history'; import { setQueries } from 'app/features/explore/state/query'; import { dispatch } from 'app/store/store'; import { StoreState } from 'app/types'; import { ShowConfirmModalEvent } from 'app/types/events'; import { RichHistoryQuery, ExploreId } from 'app/types/explore'; function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { const explore = state.explore; const { datasourceInstance } = explore[exploreId]!; return { exploreId, datasourceInstance, }; } const mapDispatchToProps = { changeDatasource, deleteHistoryItem, commentHistoryItem, starHistoryItem, setQueries, }; const connector = connect(mapStateToProps, mapDispatchToProps); interface OwnProps { query: RichHistoryQuery; } export type Props = ConnectedProps & OwnProps; const getStyles = (theme: GrafanaTheme2) => { /* Hard-coded value so all buttons and icons on right side of card are aligned */ const rightColumnWidth = '240px'; const rightColumnContentWidth = '170px'; /* If datasource was removed, card will have inactive color */ const cardColor = theme.colors.background.secondary; return { queryCard: css` position: relative; display: flex; flex-direction: column; border: 1px solid ${theme.colors.border.weak}; margin: ${theme.spacing(1)} 0; background-color: ${cardColor}; border-radius: ${theme.shape.borderRadius(1)}; .starred { color: ${theme.v1.palette.orange}; } `, cardRow: css` display: flex; align-items: center; justify-content: space-between; padding: ${theme.spacing(1)}; border-bottom: none; :first-of-type { border-bottom: 1px solid ${theme.colors.border.weak}; padding: ${theme.spacing(0.5, 1)}; } img { height: ${theme.typography.fontSize}px; max-width: ${theme.typography.fontSize}px; margin-right: ${theme.spacing(1)}; } `, queryActionButtons: css` max-width: ${rightColumnContentWidth}; display: flex; justify-content: flex-end; font-size: ${theme.typography.size.base}; button { margin-left: ${theme.spacing(1)}; } `, queryContainer: css` font-weight: ${theme.typography.fontWeightMedium}; width: calc(100% - ${rightColumnWidth}); `, updateCommentContainer: css` width: calc(100% + ${rightColumnWidth}); margin-top: ${theme.spacing(1)}; `, comment: css` overflow-wrap: break-word; font-size: ${theme.typography.bodySmall.fontSize}; font-weight: ${theme.typography.fontWeightRegular}; margin-top: ${theme.spacing(0.5)}; `, commentButtonRow: css` > * { margin-top: ${theme.spacing(1)}; margin-right: ${theme.spacing(1)}; } `, textArea: css` width: 100%; `, runButton: css` max-width: ${rightColumnContentWidth}; display: flex; justify-content: flex-end; button { height: auto; padding: ${theme.spacing(0.5, 2)}; line-height: 1.4; span { white-space: normal !important; } } `, loader: css` position: absolute; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background-color: ${theme.colors.background.secondary}; `, }; }; export function RichHistoryCard(props: Props) { const { query, commentHistoryItem, starHistoryItem, deleteHistoryItem, changeDatasource, exploreId, datasourceInstance, setQueries, } = props; const [activeUpdateComment, setActiveUpdateComment] = useState(false); const [comment, setComment] = useState(query.comment); const { value, loading } = useAsync(async () => { let dsInstance: DataSourceApi | undefined; try { dsInstance = await getDataSourceSrv().get(query.datasourceUid); } catch (e) {} return { dsInstance, queries: await Promise.all( query.queries.map(async (query) => { let datasource; if (dsInstance?.meta.mixed) { try { datasource = await getDataSourceSrv().get(query.datasource); } catch (e) {} } else { datasource = dsInstance; } return { query, datasource, }; }) ), }; }, [query.datasourceUid, query.queries]); const styles = useStyles2(getStyles); const onRunQuery = async () => { const queriesToRun = query.queries; const differentDataSource = query.datasourceUid !== datasourceInstance?.uid; if (differentDataSource) { await changeDatasource(exploreId, query.datasourceUid); } setQueries(exploreId, queriesToRun); reportInteraction('grafana_explore_query_history_run', { queryHistoryEnabled: config.queryHistoryEnabled, differentDataSource, }); }; const onCopyQuery = async () => { const datasources = [...query.queries.map((q) => q.datasource?.type || 'unknown')]; reportInteraction('grafana_explore_query_history_copy_query', { datasources, mixed: Boolean(value?.dsInstance?.meta.mixed), }); if (loading || !value) { return; } const queriesText = value.queries .map((q) => { return createQueryText(q.query, q.datasource); }) .join('\n'); copyStringToClipboard(queriesText); dispatch(notifyApp(createSuccessNotification('Query copied to clipboard'))); }; const onCreateShortLink = async () => { const link = createUrlFromRichHistory(query); await createAndCopyShortLink(link); }; const onDeleteQuery = () => { const performDelete = (queryId: string) => { deleteHistoryItem(queryId); dispatch(notifyApp(createSuccessNotification('Query deleted'))); reportInteraction('grafana_explore_query_history_deleted', { queryHistoryEnabled: config.queryHistoryEnabled, }); }; // For starred queries, we want confirmation. For non-starred, we don't. if (query.starred) { appEvents.publish( new ShowConfirmModalEvent({ title: 'Delete', text: 'Are you sure you want to permanently delete your starred query?', yesText: 'Delete', icon: 'trash-alt', onConfirm: () => performDelete(query.id), }) ); } else { performDelete(query.id); } }; const onStarrQuery = () => { starHistoryItem(query.id, !query.starred); reportInteraction('grafana_explore_query_history_starred', { queryHistoryEnabled: config.queryHistoryEnabled, newValue: !query.starred, }); }; const toggleActiveUpdateComment = () => setActiveUpdateComment(!activeUpdateComment); const onUpdateComment = () => { commentHistoryItem(query.id, comment); setActiveUpdateComment(false); reportInteraction('grafana_explore_query_history_commented', { queryHistoryEnabled: config.queryHistoryEnabled, }); }; const onCancelUpdateComment = () => { setActiveUpdateComment(false); setComment(query.comment); }; const onKeyDown = (keyEvent: React.KeyboardEvent) => { if (keyEvent.key === 'Enter' && (keyEvent.shiftKey || keyEvent.ctrlKey)) { onUpdateComment(); } if (keyEvent.key === 'Escape') { onCancelUpdateComment(); } }; const updateComment = (