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, getAppEvents } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { TextArea, Button, IconButton, useStyles2, LoadingPlaceholder } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; import { Trans, t } from 'app/core/internationalization'; 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 } from 'app/types/explore'; function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) { const explore = state.explore; const { datasourceInstance } = explore.panes[exploreId]!; return { exploreId, datasourceInstance, }; } const mapDispatchToProps = { changeDatasource, deleteHistoryItem, commentHistoryItem, starHistoryItem, setQueries, }; const connector = connect(mapStateToProps, mapDispatchToProps); interface OwnProps { queryHistoryItem: 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.radius.default}; .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 { queryHistoryItem, commentHistoryItem, starHistoryItem, deleteHistoryItem, changeDatasource, exploreId, datasourceInstance, setQueries, } = props; const [activeUpdateComment, setActiveUpdateComment] = useState(false); const [comment, setComment] = useState(queryHistoryItem.comment); const { value: historyCardData, loading } = useAsync(async () => { let datasourceInstance: DataSourceApi | undefined; try { datasourceInstance = await getDataSourceSrv().get(queryHistoryItem.datasourceUid); } catch (e) {} return { datasourceInstance, queries: await Promise.all( queryHistoryItem.queries.map(async (query) => { let datasource; if (datasourceInstance?.meta.mixed) { try { datasource = await getDataSourceSrv().get(query.datasource); } catch (e) {} } else { datasource = datasourceInstance; } return { query, datasource, }; }) ), }; }, [queryHistoryItem.datasourceUid, queryHistoryItem.queries]); const styles = useStyles2(getStyles); const onRunQuery = async () => { const queriesToRun = queryHistoryItem.queries; const differentDataSource = queryHistoryItem.datasourceUid !== datasourceInstance?.uid; if (differentDataSource) { await changeDatasource(exploreId, queryHistoryItem.datasourceUid); } setQueries(exploreId, queriesToRun); reportInteraction('grafana_explore_query_history_run', { queryHistoryEnabled: config.queryHistoryEnabled, differentDataSource, }); }; const onCopyQuery = async () => { const datasources = [...queryHistoryItem.queries.map((query) => query.datasource?.type || 'unknown')]; reportInteraction('grafana_explore_query_history_copy_query', { datasources, mixed: Boolean(historyCardData?.datasourceInstance?.meta.mixed), }); if (loading || !historyCardData) { return; } const queriesText = historyCardData.queries .map((query) => { return createQueryText(query.query, query.datasource); }) .join('\n'); copyStringToClipboard(queriesText); dispatch( notifyApp( createSuccessNotification(t('explore.rich-history-notification.query-copied', 'Query copied to clipboard')) ) ); }; const onCreateShortLink = async () => { const link = createUrlFromRichHistory(queryHistoryItem); await createAndCopyShortLink(link); }; const onDeleteQuery = () => { const performDelete = (queryId: string) => { deleteHistoryItem(queryId); dispatch( notifyApp(createSuccessNotification(t('explore.rich-history-notification.query-deleted', 'Query deleted'))) ); reportInteraction('grafana_explore_query_history_deleted', { queryHistoryEnabled: config.queryHistoryEnabled, }); }; // For starred queries, we want confirmation. For non-starred, we don't. if (queryHistoryItem.starred) { getAppEvents().publish( new ShowConfirmModalEvent({ title: t('explore.rich-history-card.delete-query-confirmation-title', 'Delete'), text: t( 'explore.rich-history-card.delete-starred-query-confirmation-text', 'Are you sure you want to permanently delete your starred query?' ), yesText: t('explore.rich-history-card.confirm-delete', 'Delete'), icon: 'trash-alt', onConfirm: () => performDelete(queryHistoryItem.id), }) ); } else { performDelete(queryHistoryItem.id); } }; const onStarrQuery = () => { starHistoryItem(queryHistoryItem.id, !queryHistoryItem.starred); reportInteraction('grafana_explore_query_history_starred', { queryHistoryEnabled: config.queryHistoryEnabled, newValue: !queryHistoryItem.starred, }); }; const toggleActiveUpdateComment = () => setActiveUpdateComment(!activeUpdateComment); const onUpdateComment = () => { commentHistoryItem(queryHistoryItem.id, comment); setActiveUpdateComment(false); reportInteraction('grafana_explore_query_history_commented', { queryHistoryEnabled: config.queryHistoryEnabled, }); }; const onCancelUpdateComment = () => { setActiveUpdateComment(false); setComment(queryHistoryItem.comment); }; const onKeyDown = (keyEvent: React.KeyboardEvent) => { if (keyEvent.key === 'Enter' && (keyEvent.shiftKey || keyEvent.ctrlKey)) { onUpdateComment(); } if (keyEvent.key === 'Escape') { onCancelUpdateComment(); } }; const updateComment = (