Files
grafana/public/app/features/explore/RichHistory/RichHistoryCard.tsx
2023-09-04 14:08:52 +01:00

430 lines
13 KiB
TypeScript

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 { 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<T extends DataQuery = DataQuery> {
query: RichHistoryQuery<T>;
}
export type Props<T extends DataQuery = DataQuery> = ConnectedProps<typeof connector> & OwnProps<T>;
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 {
query,
commentHistoryItem,
starHistoryItem,
deleteHistoryItem,
changeDatasource,
exploreId,
datasourceInstance,
setQueries,
} = props;
const [activeUpdateComment, setActiveUpdateComment] = useState(false);
const [comment, setComment] = useState<string | undefined>(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) {
getAppEvents().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 = (
<div className={styles.updateCommentContainer} aria-label={comment ? 'Update comment form' : 'Add comment form'}>
<TextArea
onKeyDown={onKeyDown}
value={comment}
placeholder={comment ? undefined : 'An optional description of what the query does.'}
onChange={(e) => setComment(e.currentTarget.value)}
className={styles.textArea}
/>
<div className={styles.commentButtonRow}>
<Button onClick={onUpdateComment}>Save comment</Button>
<Button variant="secondary" onClick={onCancelUpdateComment}>
Cancel
</Button>
</div>
</div>
);
const queryActionButtons = (
<div className={styles.queryActionButtons}>
<IconButton
name="comment-alt"
onClick={toggleActiveUpdateComment}
tooltip={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'}
/>
<IconButton name="copy" onClick={onCopyQuery} tooltip="Copy query to clipboard" />
{value?.dsInstance && (
<IconButton name="share-alt" onClick={onCreateShortLink} tooltip="Copy shortened link to clipboard" />
)}
<IconButton name="trash-alt" title="Delete query" tooltip="Delete query" onClick={onDeleteQuery} />
<IconButton
name={query.starred ? 'favorite' : 'star'}
iconType={query.starred ? 'mono' : 'default'}
onClick={onStarrQuery}
tooltip={query.starred ? 'Unstar query' : 'Star query'}
/>
</div>
);
return (
<div className={styles.queryCard}>
<div className={styles.cardRow}>
<DatasourceInfo dsApi={value?.dsInstance} size="sm" />
{queryActionButtons}
</div>
<div className={cx(styles.cardRow)}>
<div className={styles.queryContainer}>
{value?.queries.map((q, i) => {
return <Query query={q} key={`${q}-${i}`} showDsInfo={value?.dsInstance?.meta.mixed} />;
})}
{!activeUpdateComment && query.comment && (
<div aria-label="Query comment" className={styles.comment}>
{query.comment}
</div>
)}
{activeUpdateComment && updateComment}
</div>
{!activeUpdateComment && (
<div className={styles.runButton}>
<Button
variant="secondary"
onClick={onRunQuery}
disabled={!value?.dsInstance || value.queries.some((query) => !query.datasource)}
>
{datasourceInstance?.uid === query.datasourceUid ? 'Run query' : 'Switch data source and run query'}
</Button>
</div>
)}
</div>
{loading && <LoadingPlaceholder text="loading..." className={styles.loader} />}
</div>
);
}
const getQueryStyles = (theme: GrafanaTheme2) => ({
queryRow: css`
border-top: 1px solid ${theme.colors.border.weak};
display: flex;
flex-direction: row;
padding: 4px 0px;
gap: 4px;
:first-child {
border-top: none;
}
`,
dsInfoContainer: css`
display: flex;
align-items: center;
`,
queryText: css`
word-break: break-all;
`,
});
interface QueryProps {
query: {
query: DataQuery;
datasource?: DataSourceApi;
};
/** Show datasource info (icon+name) alongside the query text */
showDsInfo?: boolean;
}
const Query = ({ query, showDsInfo = false }: QueryProps) => {
const styles = useStyles2(getQueryStyles);
return (
<div className={styles.queryRow}>
{showDsInfo && (
<div className={styles.dsInfoContainer}>
<DatasourceInfo dsApi={query.datasource} size="md" />
{': '}
</div>
)}
<span aria-label="Query text" className={styles.queryText}>
{createQueryText(query.query, query.datasource)}
</span>
</div>
);
};
const getDsInfoStyles = (size: 'sm' | 'md') => (theme: GrafanaTheme2) => css`
display: flex;
align-items: center;
font-size: ${theme.typography[size === 'sm' ? 'bodySmall' : 'body'].fontSize};
font-weight: ${theme.typography.fontWeightMedium};
white-space: nowrap;
`;
function DatasourceInfo({ dsApi, size }: { dsApi?: DataSourceApi; size: 'sm' | 'md' }) {
const getStyles = useCallback((theme: GrafanaTheme2) => getDsInfoStyles(size)(theme), [size]);
const styles = useStyles2(getStyles);
return (
<div className={styles}>
<img
src={dsApi?.meta.info.logos.small || 'public/img/icn-datasource.svg'}
alt={dsApi?.type || 'Data source does not exist anymore'}
aria-label="Data source icon"
/>
<div aria-label="Data source name">{dsApi?.name || 'Data source does not exist anymore'}</div>
</div>
);
}
export default connector(RichHistoryCard);