diff --git a/public/app/core/utils/richHistory.test.ts b/public/app/core/utils/richHistory.test.ts index 77a639d71cd..a8da4776ce4 100644 --- a/public/app/core/utils/richHistory.test.ts +++ b/public/app/core/utils/richHistory.test.ts @@ -7,6 +7,7 @@ import { createQueryHeading, createDataQuery, deleteAllFromRichHistory, + deleteQueryInRichHistory, } from './richHistory'; import store from 'app/core/store'; import { SortOrder } from './explore'; @@ -135,6 +136,18 @@ describe('updateCommentInRichHistory', () => { }); }); +describe('deleteQueryInRichHistory', () => { + it('should delete query in query in history', () => { + const deletedHistory = deleteQueryInRichHistory(mock.history, 1); + expect(deletedHistory).toEqual([]); + }); + it('should delete query in localStorage', () => { + deleteQueryInRichHistory(mock.history, 1); + expect(store.exists(key)).toBeTruthy(); + expect(store.getObject(key)).toEqual([]); + }); +}); + describe('mapNumbertoTimeInSlider', () => { it('should correctly map number to value', () => { const value = mapNumbertoTimeInSlider(25); diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index deb77f6a95e..168de580bc2 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -6,6 +6,7 @@ import { DataQuery, ExploreMode } from '@grafana/data'; import { renderUrl } from 'app/core/utils/url'; import store from 'app/core/store'; import { serializeStateToUrlParam, SortOrder } from './explore'; +import { getExploreDatasources } from '../../features/explore/state/selectors'; // Types import { ExploreUrlState, RichHistoryQuery } from 'app/types/explore'; @@ -16,6 +17,7 @@ export const RICH_HISTORY_SETTING_KEYS = { retentionPeriod: 'grafana.explore.richHistory.retentionPeriod', starredTabAsFirstTab: 'grafana.explore.richHistory.starredTabAsFirstTab', activeDatasourceOnly: 'grafana.explore.richHistory.activeDatasourceOnly', + datasourceFilters: 'grafana.explore.richHistory.datasourceFilters', }; /* @@ -113,6 +115,12 @@ export function updateCommentInRichHistory( return updatedQueries; } +export function deleteQueryInRichHistory(richHistory: RichHistoryQuery[], ts: number) { + const updatedQueries = richHistory.filter(query => query.ts !== ts); + store.setObject(RICH_HISTORY_KEY, updatedQueries); + return updatedQueries; +} + export const sortQueries = (array: RichHistoryQuery[], sortOrder: SortOrder) => { let sortFunc; @@ -257,3 +265,31 @@ export function mapQueriesToHeadings(query: RichHistoryQuery[], sortOrder: SortO return mappedQueriesToHeadings; } + +/* Create datasource list with images. If specific datasource retrieved from Rich history is not part of + * exploreDatasources add generic datasource image and add property isRemoved = true. + */ +export function createDatasourcesList(queriesDatasources: string[]) { + const exploreDatasources = getExploreDatasources(); + const datasources: Array<{ label: string; value: string; imgUrl: string; isRemoved: boolean }> = []; + + queriesDatasources.forEach(queryDsName => { + const index = exploreDatasources.findIndex(exploreDs => exploreDs.name === queryDsName); + if (index !== -1) { + datasources.push({ + label: queryDsName, + value: queryDsName, + imgUrl: exploreDatasources[index].meta.info.logos.small, + isRemoved: false, + }); + } else { + datasources.push({ + label: queryDsName, + value: queryDsName, + imgUrl: 'public/img/icn-datasource.svg', + isRemoved: true, + }); + } + }); + return datasources; +} diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx index f8c4ca60ad1..f4a545edca1 100644 --- a/public/app/features/explore/RichHistory/RichHistory.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.tsx @@ -83,8 +83,8 @@ class UnThemedRichHistory extends PureComponent { + store.setObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, value); this.setState({ datasourceFilters: value }); }; @@ -133,7 +134,7 @@ class UnThemedRichHistory extends PureComponent) => { queries: ['query1', 'query2', 'query3'], sessionName: '', }, - changeQuery: jest.fn(), + dsImg: '/app/img', + isRemoved: false, changeDatasource: jest.fn(), - clearQueries: jest.fn(), updateRichHistory: jest.fn(), + setQueries: jest.fn(), exploreId: ExploreId.left, datasourceInstance: { name: 'Datasource' } as DataSourceApi, }; @@ -62,6 +63,18 @@ describe('RichHistoryCard', () => { .text() ).toEqual('query3'); }); + it('should render data source icon', () => { + const wrapper = setup(); + expect(wrapper.find({ 'aria-label': 'Data source icon' })).toHaveLength(1); + }); + it('should render data source name', () => { + const wrapper = setup(); + expect(wrapper.find({ 'aria-label': 'Data source name' }).text()).toEqual('Test datasource'); + }); + it('should render "Not linked to existing data source" if removed data source', () => { + const wrapper = setup({ isRemoved: true }); + expect(wrapper.find({ 'aria-label': 'Data source name' }).text()).toEqual('Not linked to existing data source'); + }); describe('commenting', () => { it('should render comment, if comment present', () => { diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index bf44f50c059..b4db8387bfa 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -2,54 +2,91 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; import { css, cx } from 'emotion'; -import { stylesFactory, useTheme, Forms, styleMixins } from '@grafana/ui'; +import { stylesFactory, useTheme, Forms } from '@grafana/ui'; import { GrafanaTheme, AppEvents, DataSourceApi } from '@grafana/data'; import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { copyStringToClipboard, createUrlFromRichHistory, createDataQuery } from 'app/core/utils/richHistory'; import appEvents from 'app/core/app_events'; import { StoreState } from 'app/types'; -import { changeQuery, changeDatasource, clearQueries, updateRichHistory } from '../state/actions'; +import { changeDatasource, updateRichHistory, setQueries } from '../state/actions'; export interface Props { query: RichHistoryQuery; - changeQuery: typeof changeQuery; + dsImg: string; + isRemoved: boolean; changeDatasource: typeof changeDatasource; - clearQueries: typeof clearQueries; updateRichHistory: typeof updateRichHistory; + setQueries: typeof setQueries; exploreId: ExploreId; datasourceInstance: DataSourceApi; } -const getStyles = stylesFactory((theme: GrafanaTheme, hasComment?: boolean) => { - const borderColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark4; - const cardBottomPadding = hasComment ? theme.spacing.sm : theme.spacing.xs; +const getStyles = stylesFactory((theme: GrafanaTheme, isRemoved: boolean) => { + /* Hard-coded value so all buttons and icons on right side of card are aligned */ + const rigtColumnWidth = '240px'; + const rigtColumnContentWidth = '170px'; + + const borderColor = theme.isLight ? theme.colors.gray5 : theme.colors.gray25; + + /* If datasource was removed, card will have inactive color */ + const cardColor = theme.isLight + ? isRemoved + ? theme.colors.gray95 + : theme.colors.white + : isRemoved + ? theme.colors.gray15 + : theme.colors.dark7; + const cardBoxShadow = theme.isLight ? `0px 2px 2px ${borderColor}` : `0px 2px 4px black`; return { queryCard: css` - ${styleMixins.listItem(theme)} display: flex; - padding: ${theme.spacing.sm} ${theme.spacing.sm} ${cardBottomPadding}; + flex-direction: column; + border: 1px solid ${borderColor}; margin: ${theme.spacing.sm} 0; + box-shadow: ${cardBoxShadow}; + background-color: ${cardColor}; + border-radius: ${theme.border.radius}; .starred { color: ${theme.colors.orange}; } `, - queryCardLeft: css` - padding-right: 10px; - width: calc(100% - 150px); - cursor: pointer; + cardRow: css` + display: flex; + align-items: center; + justify-content: space-between; + padding: ${theme.spacing.sm}; + border-bottom: none; + :first-of-type { + border-bottom: 1px solid ${borderColor}; + padding: ${theme.spacing.xs} ${theme.spacing.sm}; + } + img { + height: ${theme.typography.size.base}; + max-width: ${theme.typography.size.base}; + margin-right: ${theme.spacing.sm}; + } `, - queryCardRight: css` - width: 150px; - height: ${theme.height.sm}; + datasourceContainer: css` + display: flex; + align-items: center; + font-size: ${theme.typography.size.sm}; + font-weight: ${theme.typography.weight.bold}; + `, + queryActionButtons: css` + max-width: ${rigtColumnContentWidth}; display: flex; justify-content: flex-end; - + font-size: ${theme.typography.size.base}; i { margin: ${theme.spacing.xs}; cursor: pointer; } `, + queryContainer: css` + font-weight: ${theme.typography.weight.bold}; + width: calc(100% - ${rigtColumnWidth}); + `, queryRow: css` border-top: 1px solid ${borderColor}; word-break: break-all; @@ -59,142 +96,174 @@ const getStyles = stylesFactory((theme: GrafanaTheme, hasComment?: boolean) => { padding: 0 0 4px 0; } `, - buttonRow: css` - > * { - margin-right: ${theme.spacing.xs}; - } + updateCommentContainer: css` + width: calc(100% + ${rigtColumnWidth}); + margin-top: ${theme.spacing.sm}; `, comment: css` overflow-wrap: break-word; font-size: ${theme.typography.size.sm}; + font-weight: ${theme.typography.weight.regular}; margin-top: ${theme.spacing.xs}; `, + commentButtonRow: css` + > * { + margin-right: ${theme.spacing.sm}; + } + `, + textArea: css` + border: 1px solid ${borderColor}; + background: inherit; + color: inherit; + width: 100%; + font-size: ${theme.typography.size.sm}; + &placeholder { + padding: 0 ${theme.spacing.sm}; + } + `, + runButton: css` + max-width: ${rigtColumnContentWidth}; + display: flex; + justify-content: flex-end; + button { + height: auto; + padding: ${theme.spacing.sm} ${theme.spacing.md}; + span { + white-space: normal !important; + } + } + `, }; }); export function RichHistoryCard(props: Props) { const { query, + dsImg, + isRemoved, updateRichHistory, - changeQuery, changeDatasource, exploreId, - clearQueries, datasourceInstance, + setQueries, } = props; const [activeUpdateComment, setActiveUpdateComment] = useState(false); const [comment, setComment] = useState(query.comment); const toggleActiveUpdateComment = () => setActiveUpdateComment(!activeUpdateComment); const theme = useTheme(); - const styles = getStyles(theme, Boolean(query.comment)); + const styles = getStyles(theme, isRemoved); - const changeQueries = () => { - query.queries.forEach((q, i) => { - const dataQuery = createDataQuery(query, q, i); - changeQuery(exploreId, dataQuery, i); - }); - }; - - const onChangeQuery = async (query: RichHistoryQuery) => { + const onRunQuery = async () => { + const dataQueries = query.queries.map((q, i) => createDataQuery(query, q, i)); if (query.datasourceName !== datasourceInstance?.name) { await changeDatasource(exploreId, query.datasourceName); - changeQueries(); + setQueries(exploreId, dataQueries); } else { - clearQueries(exploreId); - changeQueries(); + setQueries(exploreId, dataQueries); } }; + const onCopyQuery = () => { + const queries = query.queries.join('\n\n'); + copyStringToClipboard(queries); + appEvents.emit(AppEvents.alertSuccess, ['Query copied to clipboard']); + }; + + const onCreateLink = () => { + const url = createUrlFromRichHistory(query); + copyStringToClipboard(url); + appEvents.emit(AppEvents.alertSuccess, ['Link copied to clipboard']); + }; + + const onDeleteQuery = () => { + updateRichHistory(query.ts, 'delete'); + appEvents.emit(AppEvents.alertSuccess, ['Query deleted']); + }; + + const onStarrQuery = () => { + updateRichHistory(query.ts, 'starred'); + }; + + const onUpdateComment = () => { + updateRichHistory(query.ts, 'comment', comment); + toggleActiveUpdateComment(); + }; + + const onCancelUpdateComment = () => { + toggleActiveUpdateComment(); + setComment(query.comment); + }; + + const updateComment = ( +
+ setComment(e.currentTarget.value)} + className={styles.textArea} + /> +
+ Save comment + + Cancel + +
+
+ ); + + const queryActionButtons = ( +
+ 0 ? 'Edit comment' : 'Add comment'} + > + + {!isRemoved && } + + +
+ ); + return (
-
onChangeQuery(query)}> - {query.queries.map((q, i) => { - return ( -
- {q} -
- ); - })} - {!activeUpdateComment && query.comment && ( -
- {query.comment} +
+
+ +
+ {isRemoved ? 'Not linked to existing data source' : query.datasourceName}
- )} - {activeUpdateComment && ( -
- { - setComment(e.currentTarget.value); - }} - onClick={e => { - e.stopPropagation(); - }} - /> -
- { - e.preventDefault(); - e.stopPropagation(); - updateRichHistory(query.ts, 'comment', comment); - toggleActiveUpdateComment(); - }} - > - Save - - { - e.stopPropagation(); - toggleActiveUpdateComment(); - setComment(query.comment); - }} - > - Cancel - -
-
- )} +
+ {queryActionButtons}
-
- { - toggleActiveUpdateComment(); - }} - title={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'} - > - { - const queries = query.queries.join('\n\n'); - copyStringToClipboard(queries); - appEvents.emit(AppEvents.alertSuccess, ['Query copied to clipboard']); - }} - title="Copy query to clipboard" - > - { - const url = createUrlFromRichHistory(query); - copyStringToClipboard(url); - appEvents.emit(AppEvents.alertSuccess, ['Link copied to clipboard']); - }} - style={{ fontWeight: 'normal' }} - title="Copy link to clipboard" - > - { - updateRichHistory(query.ts, 'starred'); - }} - title={query.starred ? 'Unstar query' : 'Star query'} - > +
+
+ {query.queries.map((q, i) => { + return ( +
+ {q} +
+ ); + })} + {!activeUpdateComment && query.comment && ( +
+ {query.comment} +
+ )} + {activeUpdateComment && updateComment} +
+ {!activeUpdateComment && ( +
+ + {datasourceInstance?.name === query.datasourceName ? 'Run query' : 'Switch data source and run query'} + +
+ )}
); @@ -212,10 +281,9 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI } const mapDispatchToProps = { - changeQuery, changeDatasource, - clearQueries, updateRichHistory, + setQueries, }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(RichHistoryCard)); diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx index d3e818ea772..322b7b135d9 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx @@ -8,7 +8,6 @@ import { RichHistoryQuery, ExploreId } from 'app/types/explore'; // Utils import { stylesFactory, useTheme } from '@grafana/ui'; import { GrafanaTheme, SelectableValue } from '@grafana/data'; -import { getExploreDatasources } from '../state/selectors'; import { SortOrder } from 'app/core/utils/explore'; import { @@ -16,6 +15,7 @@ import { mapNumbertoTimeInSlider, createRetentionPeriodBoundary, mapQueriesToHeadings, + createDatasourcesList, } from 'app/core/utils/richHistory'; // Components @@ -136,14 +136,8 @@ export function RichHistoryQueriesTab(props: Props) { const theme = useTheme(); const styles = getStyles(theme, height); - const listOfDsNamesWithQueries = uniqBy(queries, 'datasourceName').map(d => d.datasourceName); - - /* Display only explore datasoources, that have saved queries */ - const datasources = getExploreDatasources() - ?.filter(ds => listOfDsNamesWithQueries.includes(ds.name)) - .map(d => { - return { value: d.value!, label: d.value!, imgUrl: d.meta.info.logos.small }; - }); + const datasourcesRetrievedFromQueryHistory = uniqBy(queries, 'datasourceName').map(d => d.datasourceName); + const listOfDatasources = createDatasourcesList(datasourcesRetrievedFromQueryHistory); const listOfDatasourceFilters = datasourceFilters?.map(d => d.value); const filteredQueriesByDatasource = datasourceFilters @@ -193,7 +187,7 @@ export function RichHistoryQueriesTab(props: Props) {
{sortedStarredQueries.map(q => { - return ; + const idx = listOfDatasources.findIndex(d => d.label === q.datasourceName); + return ( + + ); })} +
+ Query history is a beta feature. The history is local to your browser and is not shared with others. + Feedback? +
); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index b27c3dc4304..dea738b9685 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -43,6 +43,7 @@ import { deleteAllFromRichHistory, updateStarredInRichHistory, updateCommentInRichHistory, + deleteQueryInRichHistory, getQueryDisplayText, getRichHistory, } from 'app/core/utils/richHistory'; @@ -525,6 +526,9 @@ export const updateRichHistory = (ts: number, property: string, updatedProperty? if (property === 'comment') { nextRichHistory = updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty); } + if (property === 'delete') { + nextRichHistory = deleteQueryInRichHistory(getState().explore.richHistory, ts); + } dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); }; };