Query History: Move local storage specific settings to persistence layer (#47500)

* Load Rich History when the container is opened

* Store rich history for each pane separately

* Do not update currently opened query history when an item is added

It's impossible to figure out if the item should be added or not, because filters are applied in the backend. We don't want to replicate that filtering logic in frontend. One way to make it work could be by refreshing both panes.

* Test starring and deleting query history items when both panes are open

* Remove e2e dependency on ExploreId

* Fix unit test

* Assert exact queries

* Simplify test

* Fix e2e tests

* Fix toolbar a11y

* Reload the history after an item is added

* Fix unit test

* Remove references to Explore from generic PageToolbar component

* Update test name

* Fix test assertion

* Add issue item to TODO

* Improve test assertion

* Simplify test setup

* Move query history settings to persistence layer

* Fix test import

* Fix unit test

* Fix unit test

* Test local storage settings API

* Code formatting

* Fix linting errors

* Add an integration test

* Add missing aria role

* Fix a11y issues

* Fix a11y issues

* Use divs instead of ul/li

Otherwis,e pa11y-ci reports the error below claiming there are no children with role=tab:

Certain ARIA roles must contain particular children
   (https://dequeuniversity.com/rules/axe/4.3/aria-required-children?application=axeAPI)

   (#reactRoot > div > main > div:nth-child(3) > div > div:nth-child(1) > div >
   div:nth-child(1) > div > div > nav > div:nth-child(2) > ul)

   <ul class="css-af3vye" role="tablist"><li class="css-1ciwanz"><a href...</ul>

* Clean up settings tab

* Remove redundant aria label

* Remove redundant container

* Clean up test assertions
This commit is contained in:
Piotr Jamróz 2022-04-12 18:55:39 +02:00 committed by GitHub
parent d0abe1bb3d
commit 5cb5141c72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 402 additions and 142 deletions

View File

@ -230,7 +230,7 @@ exports[`no enzyme tests`] = {
"public/app/features/explore/LiveLogs.test.tsx:1667605379": [ "public/app/features/explore/LiveLogs.test.tsx:1667605379": [
[2, 17, 13, "RegExp match", "2409514259"] [2, 17, 13, "RegExp match", "2409514259"]
], ],
"public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:3948011811": [ "public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:492930613": [
[1, 17, 13, "RegExp match", "2409514259"] [1, 17, 13, "RegExp match", "2409514259"]
], ],
"public/app/features/explore/RunButton.test.tsx:138299098": [ "public/app/features/explore/RunButton.test.tsx:138299098": [

View File

@ -38,18 +38,20 @@ export const Tab = React.forwardRef<HTMLAnchorElement, TabProps>(
const linkClass = cx(tabsStyles.link, active ? tabsStyles.activeStyle : tabsStyles.notActive); const linkClass = cx(tabsStyles.link, active ? tabsStyles.activeStyle : tabsStyles.notActive);
return ( return (
<li className={tabsStyles.item}> <div className={tabsStyles.item}>
<a <a
href={href} href={href}
className={linkClass} className={linkClass}
{...otherProps} {...otherProps}
onClick={onChangeTab} onClick={onChangeTab}
aria-label={otherProps['aria-label'] || selectors.components.Tab.title(label)} aria-label={otherProps['aria-label'] || selectors.components.Tab.title(label)}
role="tab"
aria-selected={active}
ref={ref} ref={ref}
> >
{content()} {content()}
</a> </a>
</li> </div>
); );
} }
); );

View File

@ -32,7 +32,9 @@ export const TabsBar = React.forwardRef<HTMLDivElement, Props>(({ children, clas
return ( return (
<div className={cx(tabsStyles.tabsWrapper, className)} ref={ref}> <div className={cx(tabsStyles.tabsWrapper, className)} ref={ref}>
<ul className={tabsStyles.tabs}>{children}</ul> <div className={tabsStyles.tabs} role="tablist">
{children}
</div>
</div> </div>
); );
}); });

View File

@ -5,6 +5,7 @@ import { DataQuery } from '@grafana/data';
import { afterEach, beforeEach } from '../../../test/lib/common'; import { afterEach, beforeEach } from '../../../test/lib/common';
import { RichHistoryStorageWarning } from './RichHistoryStorage'; import { RichHistoryStorageWarning } from './RichHistoryStorage';
import { backendSrv } from '../services/backend_srv'; import { backendSrv } from '../services/backend_srv';
import { RichHistorySettings } from '../utils/richHistoryTypes';
const key = 'grafana.explore.richHistory'; const key = 'grafana.explore.richHistory';
@ -99,6 +100,19 @@ describe('RichHistoryLocalStorage', () => {
expect(await storage.getRichHistory()).toEqual([]); expect(await storage.getRichHistory()).toEqual([]);
expect(store.getObject(key)).toEqual([]); expect(store.getObject(key)).toEqual([]);
}); });
it('should save and read settings', async () => {
const settings: RichHistorySettings = {
retentionPeriod: 2,
starredTabAsFirstTab: true,
activeDatasourceOnly: true,
lastUsedDatasourceFilters: [{ value: 'foobar' }],
};
await storage.updateSettings(settings);
const storageSettings = storage.getSettings();
expect(settings).toMatchObject(storageSettings);
});
}); });
describe('retention policy and max limits', () => { describe('retention policy and max limits', () => {

View File

@ -5,6 +5,7 @@ import { DataQuery } from '@grafana/data';
import { find, isEqual, omit } from 'lodash'; import { find, isEqual, omit } from 'lodash';
import { createRetentionPeriodBoundary, RICH_HISTORY_SETTING_KEYS } from './richHistoryLocalStorageUtils'; import { createRetentionPeriodBoundary, RICH_HISTORY_SETTING_KEYS } from './richHistoryLocalStorageUtils';
import { fromDTO, toDTO } from './localStorageConverter'; import { fromDTO, toDTO } from './localStorageConverter';
import { RichHistorySettings } from '../utils/richHistoryTypes';
export const RICH_HISTORY_KEY = 'grafana.explore.richHistory'; export const RICH_HISTORY_KEY = 'grafana.explore.richHistory';
export const MAX_HISTORY_ITEMS = 10000; export const MAX_HISTORY_ITEMS = 10000;
@ -101,6 +102,22 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
async updateComment(id: string, comment: string) { async updateComment(id: string, comment: string) {
return updateRichHistory(id, (richHistoryDTO) => (richHistoryDTO.comment = comment)); return updateRichHistory(id, (richHistoryDTO) => (richHistoryDTO.comment = comment));
} }
async getSettings() {
return {
activeDatasourceOnly: store.getObject(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, false),
retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7),
starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false),
lastUsedDatasourceFilters: store.getObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, []),
};
}
async updateSettings(settings: RichHistorySettings) {
store.set(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, settings.activeDatasourceOnly);
store.set(RICH_HISTORY_SETTING_KEYS.retentionPeriod, settings.retentionPeriod);
store.set(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, settings.starredTabAsFirstTab);
store.setObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, settings.lastUsedDatasourceFilters);
}
} }
function updateRichHistory( function updateRichHistory(

View File

@ -1,4 +1,5 @@
import { RichHistoryQuery } from '../../types'; import { RichHistoryQuery } from '../../types';
import { RichHistorySettings } from '../utils/richHistoryTypes';
/** /**
* Errors are used when the operation on Rich History was not successful. * Errors are used when the operation on Rich History was not successful.
@ -44,4 +45,7 @@ export default interface RichHistoryStorage {
deleteRichHistory(id: string): Promise<void>; deleteRichHistory(id: string): Promise<void>;
updateStarred(id: string, starred: boolean): Promise<RichHistoryQuery>; updateStarred(id: string, starred: boolean): Promise<RichHistoryQuery>;
updateComment(id: string, comment: string | undefined): Promise<RichHistoryQuery>; updateComment(id: string, comment: string | undefined): Promise<RichHistoryQuery>;
getSettings(): Promise<RichHistorySettings>;
updateSettings(settings: RichHistorySettings): Promise<void>;
} }

View File

@ -23,7 +23,7 @@ import {
filterQueriesByTime, filterQueriesByTime,
sortQueries, sortQueries,
} from 'app/core/history/richHistoryLocalStorageUtils'; } from 'app/core/history/richHistoryLocalStorageUtils';
import { SortOrder } from './richHistoryTypes'; import { RichHistorySettings, SortOrder } from './richHistoryTypes';
export { SortOrder }; export { SortOrder };
@ -86,6 +86,14 @@ export async function getRichHistory(): Promise<RichHistoryQuery[]> {
return await getRichHistoryStorage().getRichHistory(); return await getRichHistoryStorage().getRichHistory();
} }
export async function updateRichHistorySettings(settings: RichHistorySettings): Promise<void> {
await getRichHistoryStorage().updateSettings(settings);
}
export async function getRichHistorySettings(): Promise<RichHistorySettings> {
return await getRichHistoryStorage().getSettings();
}
export async function deleteAllFromRichHistory(): Promise<void> { export async function deleteAllFromRichHistory(): Promise<void> {
return getRichHistoryStorage().deleteAll(); return getRichHistoryStorage().deleteAll();
} }

View File

@ -1,6 +1,23 @@
import { SelectableValue } from '@grafana/data';
export enum SortOrder { export enum SortOrder {
Descending = 'Descending', Descending = 'Descending',
Ascending = 'Ascending', Ascending = 'Ascending',
DatasourceAZ = 'Datasource A-Z', DatasourceAZ = 'Datasource A-Z',
DatasourceZA = 'Datasource Z-A', DatasourceZA = 'Datasource Z-A',
} }
export interface RichHistorySettings {
retentionPeriod: number;
starredTabAsFirstTab: boolean;
activeDatasourceOnly: boolean;
lastUsedDatasourceFilters: SelectableValue[];
}
export type RichHistorySearchFilters = {
search: string;
sortOrder: SortOrder;
datasourceFilters: SelectableValue[];
from: number;
to: number;
};

View File

@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { ExploreId } from '../../../types/explore'; import { ExploreId } from '../../../types/explore';
import { RichHistory, RichHistoryProps, Tabs } from './RichHistory'; import { RichHistory, RichHistoryProps, Tabs } from './RichHistory';
import { SortOrder } from '../../../core/utils/richHistoryTypes';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() })); jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
@ -16,6 +17,21 @@ const setup = (propOverrides?: Partial<RichHistoryProps>) => {
firstTab: Tabs.RichHistory, firstTab: Tabs.RichHistory,
deleteRichHistory: jest.fn(), deleteRichHistory: jest.fn(),
onClose: jest.fn(), onClose: jest.fn(),
richHistorySearchFilters: {
search: '',
sortOrder: SortOrder.Descending,
datasourceFilters: [],
from: 0,
to: 7,
},
richHistorySettings: {
retentionPeriod: 0,
starredTabAsFirstTab: false,
activeDatasourceOnly: true,
lastUsedDatasourceFilters: [],
},
updateHistorySearchFilters: jest.fn(),
updateHistorySettings: jest.fn(),
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);

View File

@ -2,8 +2,6 @@ import React, { PureComponent } from 'react';
//Services & Utils //Services & Utils
import { SortOrder } from 'app/core/utils/richHistory'; import { SortOrder } from 'app/core/utils/richHistory';
import { RICH_HISTORY_SETTING_KEYS } from 'app/core/history/richHistoryLocalStorageUtils';
import store from 'app/core/store';
import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui'; import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui';
//Types //Types
@ -11,9 +9,10 @@ import { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
//Components //Components
import { RichHistorySettings } from './RichHistorySettings'; import { RichHistorySettingsTab } from './RichHistorySettingsTab';
import { RichHistoryQueriesTab } from './RichHistoryQueriesTab'; import { RichHistoryQueriesTab } from './RichHistoryQueriesTab';
import { RichHistoryStarredTab } from './RichHistoryStarredTab'; import { RichHistoryStarredTab } from './RichHistoryStarredTab';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
export enum Tabs { export enum Tabs {
RichHistory = 'Query history', RichHistory = 'Query history',
@ -30,100 +29,74 @@ export const sortOrderOptions = [
export interface RichHistoryProps extends Themeable { export interface RichHistoryProps extends Themeable {
richHistory: RichHistoryQuery[]; richHistory: RichHistoryQuery[];
richHistorySettings: RichHistorySettings;
richHistorySearchFilters: RichHistorySearchFilters;
updateHistorySettings: (settings: RichHistorySettings) => void;
updateHistorySearchFilters: (exploreId: ExploreId, filters: RichHistorySearchFilters) => void;
deleteRichHistory: () => void;
activeDatasourceInstance?: string; activeDatasourceInstance?: string;
firstTab: Tabs; firstTab: Tabs;
exploreId: ExploreId; exploreId: ExploreId;
height: number; height: number;
deleteRichHistory: () => void;
onClose: () => void; onClose: () => void;
} }
interface RichHistoryState { class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
sortOrder: SortOrder; updateSettings = (settingsToUpdate: Partial<RichHistorySettings>) => {
retentionPeriod: number; this.props.updateHistorySettings({ ...this.props.richHistorySettings, ...settingsToUpdate });
starredTabAsFirstTab: boolean; };
activeDatasourceOnly: boolean;
datasourceFilters: SelectableValue[]; updateFilters = (filtersToUpdate: Partial<RichHistorySearchFilters>) => {
} this.props.updateHistorySearchFilters(this.props.exploreId, {
...this.props.richHistorySearchFilters,
class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistoryState> { ...filtersToUpdate,
constructor(props: RichHistoryProps) { });
super(props);
this.state = {
sortOrder: SortOrder.Descending,
datasourceFilters: store.getObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, []),
retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7),
starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false),
activeDatasourceOnly: store.getBool(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, true),
}; };
}
onChangeRetentionPeriod = (retentionPeriod: SelectableValue<number>) => { onChangeRetentionPeriod = (retentionPeriod: SelectableValue<number>) => {
if (retentionPeriod.value !== undefined) { if (retentionPeriod.value !== undefined) {
this.setState({ this.updateSettings({ retentionPeriod: retentionPeriod.value });
retentionPeriod: retentionPeriod.value,
});
store.set(RICH_HISTORY_SETTING_KEYS.retentionPeriod, retentionPeriod.value);
} }
}; };
toggleStarredTabAsFirstTab = () => { toggleStarredTabAsFirstTab = () =>
const starredTabAsFirstTab = !this.state.starredTabAsFirstTab; this.updateSettings({ starredTabAsFirstTab: !this.props.richHistorySettings.starredTabAsFirstTab });
this.setState({
starredTabAsFirstTab,
});
store.set(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, starredTabAsFirstTab);
};
toggleActiveDatasourceOnly = () => { toggleActiveDatasourceOnly = () =>
const activeDatasourceOnly = !this.state.activeDatasourceOnly; this.updateSettings({ activeDatasourceOnly: !this.props.richHistorySettings.activeDatasourceOnly });
this.setState({
activeDatasourceOnly,
});
store.set(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, activeDatasourceOnly);
};
onSelectDatasourceFilters = (value: SelectableValue[]) => { onSelectDatasourceFilters = (datasourceFilters: SelectableValue[]) => this.updateFilters({ datasourceFilters });
try {
store.setObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, value);
} catch (error) {
console.error(error);
}
/* Set data source filters to state even though they were not successfully saved in
* localStorage to allow interaction and filtering.
**/
this.setState({ datasourceFilters: value });
};
onChangeSortOrder = (sortOrder: SortOrder) => this.setState({ sortOrder }); onChangeSortOrder = (sortOrder: SortOrder) => this.updateFilters({ sortOrder });
/* If user selects activeDatasourceOnly === true, set datasource filter to currently active datasource. /* If user selects activeDatasourceOnly === true, set datasource filter to currently active datasource.
* Filtering based on datasource won't be available. Otherwise set to null, as filtering will be * Filtering based on datasource won't be available. Otherwise set to null, as filtering will be
* available for user. * available for user.
*/ */
updateFilters() { initFilters() {
this.state.activeDatasourceOnly && this.props.activeDatasourceInstance if (this.props.richHistorySettings.activeDatasourceOnly && this.props.activeDatasourceInstance) {
? this.onSelectDatasourceFilters([ this.onSelectDatasourceFilters([
{ label: this.props.activeDatasourceInstance, value: this.props.activeDatasourceInstance }, { label: this.props.activeDatasourceInstance, value: this.props.activeDatasourceInstance },
]) ]);
: this.onSelectDatasourceFilters(this.state.datasourceFilters); }
} }
componentDidMount() { componentDidMount() {
this.updateFilters(); this.initFilters();
} }
componentDidUpdate(prevProps: RichHistoryProps, prevState: RichHistoryState) { /**
if ( * Updating filters on didMount and didUpdate because we don't know when activeDatasourceInstance is ready
this.props.activeDatasourceInstance !== prevProps.activeDatasourceInstance || */
this.state.activeDatasourceOnly !== prevState.activeDatasourceOnly componentDidUpdate(prevProps: RichHistoryProps) {
) { if (this.props.activeDatasourceInstance !== prevProps.activeDatasourceInstance) {
this.updateFilters(); this.initFilters();
} }
} }
render() { render() {
const { datasourceFilters, sortOrder, activeDatasourceOnly, retentionPeriod } = this.state; const { activeDatasourceOnly, retentionPeriod } = this.props.richHistorySettings;
const { datasourceFilters, sortOrder } = this.props.richHistorySearchFilters;
const { richHistory, height, exploreId, deleteRichHistory, onClose, firstTab } = this.props; const { richHistory, height, exploreId, deleteRichHistory, onClose, firstTab } = this.props;
const QueriesTab: TabConfig = { const QueriesTab: TabConfig = {
@ -166,10 +139,10 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
label: 'Settings', label: 'Settings',
value: Tabs.Settings, value: Tabs.Settings,
content: ( content: (
<RichHistorySettings <RichHistorySettingsTab
retentionPeriod={this.state.retentionPeriod} retentionPeriod={this.props.richHistorySettings.retentionPeriod}
starredTabAsFirstTab={this.state.starredTabAsFirstTab} starredTabAsFirstTab={this.props.richHistorySettings.starredTabAsFirstTab}
activeDatasourceOnly={this.state.activeDatasourceOnly} activeDatasourceOnly={this.props.richHistorySettings.activeDatasourceOnly}
onChangeRetentionPeriod={this.onChangeRetentionPeriod} onChangeRetentionPeriod={this.onChangeRetentionPeriod}
toggleStarredTabAsFirstTab={this.toggleStarredTabAsFirstTab} toggleStarredTabAsFirstTab={this.toggleStarredTabAsFirstTab}
toggleactiveDatasourceOnly={this.toggleActiveDatasourceOnly} toggleactiveDatasourceOnly={this.toggleActiveDatasourceOnly}

View File

@ -4,6 +4,7 @@ import { render } from '@testing-library/react';
import { ExploreId } from '../../../types/explore'; import { ExploreId } from '../../../types/explore';
import { RichHistoryContainer, Props } from './RichHistoryContainer'; import { RichHistoryContainer, Props } from './RichHistoryContainer';
import { Tabs } from './RichHistory'; import { Tabs } from './RichHistory';
import { SortOrder } from '../../../core/utils/richHistoryTypes';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() })); jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
@ -15,8 +16,23 @@ const setup = (propOverrides?: Partial<Props>) => {
richHistory: [], richHistory: [],
firstTab: Tabs.RichHistory, firstTab: Tabs.RichHistory,
deleteRichHistory: jest.fn(), deleteRichHistory: jest.fn(),
loadRichHistory: jest.fn(), initRichHistory: jest.fn(),
updateHistorySearchFilters: jest.fn(),
updateHistorySettings: jest.fn(),
onClose: jest.fn(), onClose: jest.fn(),
richHistorySearchFilters: {
search: '',
sortOrder: SortOrder.Descending,
datasourceFilters: [],
from: 0,
to: 7,
},
richHistorySettings: {
retentionPeriod: 0,
starredTabAsFirstTab: false,
activeDatasourceOnly: true,
lastUsedDatasourceFilters: [],
},
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
@ -25,6 +41,10 @@ const setup = (propOverrides?: Partial<Props>) => {
}; };
describe('RichHistoryContainer', () => { describe('RichHistoryContainer', () => {
it('should show loading message when settings and filters are not ready', () => {
const { container } = setup({ richHistorySearchFilters: undefined, richHistorySettings: undefined });
expect(container).toHaveTextContent('Loading...');
});
it('should render component with correct width', () => { it('should render component with correct width', () => {
const { container } = setup(); const { container } = setup();
expect(container.firstElementChild!.getAttribute('style')).toContain('width: 531.5px'); expect(container.firstElementChild!.getAttribute('style')).toContain('width: 531.5px');
@ -34,14 +54,14 @@ describe('RichHistoryContainer', () => {
expect(container.firstElementChild!.getAttribute('style')).toContain('height: 400px'); expect(container.firstElementChild!.getAttribute('style')).toContain('height: 400px');
}); });
it('should re-request rich history every time the component is mounted', () => { it('should re-request rich history every time the component is mounted', () => {
const loadRichHistory = jest.fn(); const initRichHistory = jest.fn();
const { unmount } = setup({ loadRichHistory }); const { unmount } = setup({ initRichHistory });
expect(loadRichHistory).toBeCalledTimes(1); expect(initRichHistory).toBeCalledTimes(1);
unmount(); unmount();
expect(loadRichHistory).toBeCalledTimes(1); expect(initRichHistory).toBeCalledTimes(1);
setup({ loadRichHistory }); setup({ initRichHistory });
expect(loadRichHistory).toBeCalledTimes(2); expect(initRichHistory).toBeCalledTimes(2);
}); });
}); });

View File

@ -2,10 +2,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
// Services & Utils
import store from 'app/core/store';
import { RICH_HISTORY_SETTING_KEYS } from 'app/core/history/richHistoryLocalStorageUtils';
// Types // Types
import { ExploreItemState, StoreState } from 'app/types'; import { ExploreItemState, StoreState } from 'app/types';
import { ExploreId } from 'app/types/explore'; import { ExploreId } from 'app/types/explore';
@ -14,27 +10,36 @@ import { ExploreId } from 'app/types/explore';
import { RichHistory, Tabs } from './RichHistory'; import { RichHistory, Tabs } from './RichHistory';
//Actions //Actions
import { deleteRichHistory, loadRichHistory } from '../state/history'; import {
deleteRichHistory,
initRichHistory,
updateHistorySettings,
updateHistorySearchFilters,
} from '../state/history';
import { ExploreDrawer } from '../ExploreDrawer'; import { ExploreDrawer } from '../ExploreDrawer';
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) {
const explore = state.explore; const explore = state.explore;
// @ts-ignore // @ts-ignore
const item: ExploreItemState = explore[exploreId]; const item: ExploreItemState = explore[exploreId];
const richHistorySearchFilters = item.richHistorySearchFilters;
const richHistorySettings = explore.richHistorySettings;
const { datasourceInstance } = item; const { datasourceInstance } = item;
const firstTab = store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false) const firstTab = richHistorySettings?.starredTabAsFirstTab ? Tabs.Starred : Tabs.RichHistory;
? Tabs.Starred
: Tabs.RichHistory;
const { richHistory } = item; const { richHistory } = item;
return { return {
richHistory, richHistory,
firstTab, firstTab,
activeDatasourceInstance: datasourceInstance?.name, activeDatasourceInstance: datasourceInstance?.name,
richHistorySettings,
richHistorySearchFilters,
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
loadRichHistory, initRichHistory,
updateHistorySettings,
updateHistorySearchFilters,
deleteRichHistory, deleteRichHistory,
}; };
@ -57,13 +62,21 @@ export function RichHistoryContainer(props: Props) {
activeDatasourceInstance, activeDatasourceInstance,
exploreId, exploreId,
deleteRichHistory, deleteRichHistory,
loadRichHistory, initRichHistory,
richHistorySettings,
updateHistorySettings,
richHistorySearchFilters,
updateHistorySearchFilters,
onClose, onClose,
} = props; } = props;
useEffect(() => { useEffect(() => {
loadRichHistory(exploreId); initRichHistory(exploreId);
}, [loadRichHistory, exploreId]); }, [initRichHistory, exploreId]);
if (!richHistorySettings || !richHistorySearchFilters) {
return <span>Loading...</span>;
}
return ( return (
<ExploreDrawer <ExploreDrawer
@ -77,9 +90,13 @@ export function RichHistoryContainer(props: Props) {
firstTab={firstTab} firstTab={firstTab}
activeDatasourceInstance={activeDatasourceInstance} activeDatasourceInstance={activeDatasourceInstance}
exploreId={exploreId} exploreId={exploreId}
deleteRichHistory={deleteRichHistory}
onClose={onClose} onClose={onClose}
height={height} height={height}
deleteRichHistory={deleteRichHistory}
richHistorySettings={richHistorySettings}
richHistorySearchFilters={richHistorySearchFilters}
updateHistorySettings={updateHistorySettings}
updateHistorySearchFilters={updateHistorySearchFilters}
/> />
</ExploreDrawer> </ExploreDrawer>
); );

View File

@ -200,15 +200,15 @@ export function RichHistoryQueriesTab(props: Props) {
<div className={styles.containerContent}> <div className={styles.containerContent}>
<div className={styles.selectors}> <div className={styles.selectors}>
{!activeDatasourceOnly && ( {!activeDatasourceOnly && (
<div aria-label="Filter datasources" className={styles.multiselect}>
<MultiSelect <MultiSelect
className={styles.multiselect}
menuShouldPortal menuShouldPortal
options={listOfDatasources} options={listOfDatasources}
value={datasourceFilters} value={datasourceFilters}
placeholder="Filter queries for data sources(s)" placeholder="Filter queries for data sources(s)"
aria-label="Filter queries for data sources(s)"
onChange={onSelectDatasourceFilters} onChange={onSelectDatasourceFilters}
/> />
</div>
)} )}
<div className={styles.filterInput}> <div className={styles.filterInput}>
<FilterInput <FilterInput

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { RichHistorySettings, RichHistorySettingsProps } from './RichHistorySettings'; import { RichHistorySettingsTab, RichHistorySettingsProps } from './RichHistorySettingsTab';
const setup = (propOverrides?: Partial<RichHistorySettingsProps>) => { const setup = (propOverrides?: Partial<RichHistorySettingsProps>) => {
const props: RichHistorySettingsProps = { const props: RichHistorySettingsProps = {
@ -15,7 +15,7 @@ const setup = (propOverrides?: Partial<RichHistorySettingsProps>) => {
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
return render(<RichHistorySettings {...props} />); return render(<RichHistorySettingsTab {...props} />);
}; };
describe('RichHistorySettings', () => { describe('RichHistorySettings', () => {

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { stylesFactory, useTheme, Select, Button, Switch, Field } from '@grafana/ui'; import { stylesFactory, useTheme, Select, Button, Field, InlineField, InlineSwitch } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { ShowConfirmModalEvent } from '../../../types/events'; import { ShowConfirmModalEvent } from '../../../types/events';
@ -30,13 +30,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
input: css` input: css`
max-width: 200px; max-width: 200px;
`, `,
switch: css`
display: flex;
align-items: center;
`,
label: css`
margin-left: ${theme.spacing.md};
`,
}; };
}); });
@ -47,7 +40,7 @@ const retentionPeriodOptions = [
{ value: 14, label: '2 weeks' }, { value: 14, label: '2 weeks' },
]; ];
export function RichHistorySettings(props: RichHistorySettingsProps) { export function RichHistorySettingsTab(props: RichHistorySettingsProps) {
const { const {
retentionPeriod, retentionPeriod,
starredTabAsFirstTab, starredTabAsFirstTab,
@ -92,18 +85,20 @@ export function RichHistorySettings(props: RichHistorySettingsProps) {
></Select> ></Select>
</div> </div>
</Field> </Field>
<Field label="Default active tab" description=" " className="space-between"> <InlineField label="Change the default active tab from “Query history” to “Starred”" className="space-between">
<div className={styles.switch}> <InlineSwitch
<Switch value={starredTabAsFirstTab} onChange={toggleStarredTabAsFirstTab}></Switch> id="explore-query-history-settings-default-active-tab"
<div className={styles.label}>Change the default active tab from Query history to Starred</div> value={starredTabAsFirstTab}
</div> onChange={toggleStarredTabAsFirstTab}
</Field> />
<Field label="Data source behaviour" description=" " className="space-between"> </InlineField>
<div className={styles.switch}> <InlineField label="Only show queries for data source currently active in Explore" className="space-between">
<Switch value={activeDatasourceOnly} onChange={toggleactiveDatasourceOnly}></Switch> <InlineSwitch
<div className={styles.label}>Only show queries for data source currently active in Explore</div> id="explore-query-history-settings-data-source-behavior"
</div> value={activeDatasourceOnly}
</Field> onChange={toggleactiveDatasourceOnly}
/>
</InlineField>
<div <div
className={css` className={css`
font-weight: ${theme.typography.weight.bold}; font-weight: ${theme.typography.weight.bold};

View File

@ -34,12 +34,12 @@ describe('RichHistoryStarredTab', () => {
describe('select datasource', () => { describe('select datasource', () => {
it('should render select datasource if activeDatasourceOnly is false', () => { it('should render select datasource if activeDatasourceOnly is false', () => {
const wrapper = setup(); const wrapper = setup();
expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(1); expect(wrapper.find({ 'aria-label': 'Filter queries for data sources(s)' }).exists()).toBeTruthy();
}); });
it('should not render select datasource if activeDatasourceOnly is true', () => { it('should not render select datasource if activeDatasourceOnly is true', () => {
const wrapper = setup({ activeDatasourceOnly: true }); const wrapper = setup({ activeDatasourceOnly: true });
expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(0); expect(wrapper.find({ 'aria-label': 'Filter queries for data sources(s)' }).exists()).toBeFalsy();
}); });
}); });
}); });

View File

@ -117,15 +117,15 @@ export function RichHistoryStarredTab(props: Props) {
<div className={styles.containerContent}> <div className={styles.containerContent}>
<div className={styles.selectors}> <div className={styles.selectors}>
{!activeDatasourceOnly && ( {!activeDatasourceOnly && (
<div aria-label="Filter datasources" className={styles.multiselect}>
<MultiSelect <MultiSelect
className={styles.multiselect}
menuShouldPortal menuShouldPortal
options={listOfDatasources} options={listOfDatasources}
value={datasourceFilters} value={datasourceFilters}
placeholder="Filter queries for specific data sources(s)" placeholder="Filter queries for data sources(s)"
aria-label="Filter queries for data sources(s)"
onChange={onSelectDatasourceFilters} onChange={onSelectDatasourceFilters}
/> />
</div>
)} )}
<div className={styles.filterInput}> <div className={styles.filterInput}>
<FilterInput <FilterInput

View File

@ -30,3 +30,19 @@ export const assertQueryHistoryIsStarred = async (expectedStars: boolean[], expl
}) })
); );
}; };
export const assertQueryHistoryTabIsSelected = (
tabName: 'Query history' | 'Starred' | 'Settings',
exploreId: ExploreId = ExploreId.left
) => {
expect(withinExplore(exploreId).getByRole('tab', { name: `Tab ${tabName}`, selected: true })).toBeInTheDocument();
};
export const assertDataSourceFilterVisibility = (visible: boolean, exploreId: ExploreId = ExploreId.left) => {
const filterInput = withinExplore(exploreId).queryByLabelText('Filter queries for data sources(s)');
if (visible) {
expect(filterInput).toBeInTheDocument();
} else {
expect(filterInput).not.toBeInTheDocument();
}
};

View File

@ -33,6 +33,30 @@ export const openQueryHistory = async (exploreId: ExploreId = ExploreId.left) =>
).toBeInTheDocument(); ).toBeInTheDocument();
}; };
export const closeQueryHistory = async (exploreId: ExploreId = ExploreId.left) => {
const closeButton = withinExplore(exploreId).getByRole('button', { name: 'Close query history' });
userEvent.click(closeButton);
};
export const switchToQueryHistoryTab = async (
name: 'Settings' | 'Query History',
exploreId: ExploreId = ExploreId.left
) => {
userEvent.click(withinExplore(exploreId).getByRole('tab', { name: `Tab ${name}` }));
};
export const selectStarredTabFirst = (exploreId: ExploreId = ExploreId.left) => {
const checkbox = withinExplore(exploreId).getByRole('checkbox', {
name: 'Change the default active tab from “Query history” to “Starred”',
});
userEvent.click(checkbox);
};
export const selectOnlyActiveDataSource = (exploreId: ExploreId = ExploreId.left) => {
const checkbox = withinExplore(exploreId).getByLabelText(/Only show queries for data source currently active.*/);
userEvent.click(checkbox);
};
export const starQueryHistory = (queryIndex: number, exploreId: ExploreId = ExploreId.left) => { export const starQueryHistory = (queryIndex: number, exploreId: ExploreId = ExploreId.left) => {
invokeAction(queryIndex, 'Star query', exploreId); invokeAction(queryIndex, 'Star query', exploreId);
}; };

View File

@ -1,8 +1,24 @@
import React from 'react'; import React from 'react';
import { serializeStateToUrlParam } from '@grafana/data'; import { serializeStateToUrlParam } from '@grafana/data';
import { setupExplore, tearDown, waitForExplore } from './helper/setup'; import { setupExplore, tearDown, waitForExplore } from './helper/setup';
import { deleteQueryHistory, inputQuery, openQueryHistory, runQuery, starQueryHistory } from './helper/interactions'; import {
import { assertQueryHistory, assertQueryHistoryExists, assertQueryHistoryIsStarred } from './helper/assert'; closeQueryHistory,
deleteQueryHistory,
inputQuery,
openQueryHistory,
runQuery,
selectOnlyActiveDataSource,
selectStarredTabFirst,
starQueryHistory,
switchToQueryHistoryTab,
} from './helper/interactions';
import {
assertDataSourceFilterVisibility,
assertQueryHistory,
assertQueryHistoryExists,
assertQueryHistoryIsStarred,
assertQueryHistoryTabIsSelected,
} from './helper/assert';
import { makeLogsQueryResponse } from './helper/query'; import { makeLogsQueryResponse } from './helper/query';
import { ExploreId } from '../../../types'; import { ExploreId } from '../../../types';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
@ -106,4 +122,26 @@ describe('Explore: Query History', () => {
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left); await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left);
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.right); await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.right);
}); });
it('updates query history settings', async () => {
// open settings page
setupExplore();
await waitForExplore();
await openQueryHistory();
// assert default values
assertQueryHistoryTabIsSelected('Query history');
assertDataSourceFilterVisibility(true);
await switchToQueryHistoryTab('Settings');
// change settings
selectStarredTabFirst();
selectOnlyActiveDataSource();
await closeQueryHistory();
await openQueryHistory();
// assert new settings
assertQueryHistoryTabIsSelected('Starred');
assertDataSourceFilterVisibility(false);
});
}); });

View File

@ -36,7 +36,7 @@ import {
import { ThunkResult } from 'app/types'; import { ThunkResult } from 'app/types';
import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { richHistoryUpdatedAction, stateSave } from './main'; import { richHistorySearchFiltersUpdatedAction, richHistoryUpdatedAction, stateSave } from './main';
import { keybindingSrv } from 'app/core/services/keybindingSrv'; import { keybindingSrv } from 'app/core/services/keybindingSrv';
// //
@ -262,6 +262,14 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
}; };
} }
if (richHistorySearchFiltersUpdatedAction.match(action)) {
const richHistorySearchFilters = action.payload.filters;
return {
...state,
richHistorySearchFilters,
};
}
if (changeSizeAction.match(action)) { if (changeSizeAction.match(action)) {
const containerWidth = action.payload.width; const containerWidth = action.payload.width;
return { ...state, containerWidth }; return { ...state, containerWidth };

View File

@ -3,13 +3,23 @@ import {
deleteAllFromRichHistory, deleteAllFromRichHistory,
deleteQueryInRichHistory, deleteQueryInRichHistory,
getRichHistory, getRichHistory,
getRichHistorySettings,
SortOrder,
updateCommentInRichHistory, updateCommentInRichHistory,
updateRichHistorySettings,
updateStarredInRichHistory, updateStarredInRichHistory,
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types'; import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types';
import { richHistoryLimitExceededAction, richHistoryStorageFullAction, richHistoryUpdatedAction } from './main'; import {
richHistoryLimitExceededAction,
richHistorySearchFiltersUpdatedAction,
richHistorySettingsUpdatedAction,
richHistoryStorageFullAction,
richHistoryUpdatedAction,
} from './main';
import { DataQuery, HistoryItem } from '@grafana/data'; import { DataQuery, HistoryItem } from '@grafana/data';
import { AnyAction, createAction } from '@reduxjs/toolkit'; import { AnyAction, createAction } from '@reduxjs/toolkit';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
// //
// Actions and Payloads // Actions and Payloads
@ -106,11 +116,68 @@ export const deleteRichHistory = (): ThunkResult<void> => {
export const loadRichHistory = (exploreId: ExploreId): ThunkResult<void> => { export const loadRichHistory = (exploreId: ExploreId): ThunkResult<void> => {
return async (dispatch) => { return async (dispatch) => {
// TODO: #45379 pass currently applied search filters
const richHistory = await getRichHistory(); const richHistory = await getRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory, exploreId })); dispatch(richHistoryUpdatedAction({ richHistory, exploreId }));
}; };
}; };
/**
* Initialize query history pane. To load history it requires settings to be loaded first
* (but only once per session) and filters initialised with default values based on settings.
*/
export const initRichHistory = (exploreId: ExploreId): ThunkResult<void> => {
return async (dispatch, getState) => {
let settings = getState().explore.richHistorySettings;
if (!settings) {
settings = await getRichHistorySettings();
dispatch(richHistorySettingsUpdatedAction(settings));
}
dispatch(
richHistorySearchFiltersUpdatedAction({
exploreId,
filters: {
search: '',
sortOrder: SortOrder.Descending,
datasourceFilters: settings!.lastUsedDatasourceFilters || [],
from: 0,
to: settings!.retentionPeriod,
},
})
);
dispatch(loadRichHistory(exploreId));
};
};
export const updateHistorySettings = (settings: RichHistorySettings): ThunkResult<void> => {
return async (dispatch) => {
dispatch(richHistorySettingsUpdatedAction(settings));
await updateRichHistorySettings(settings);
};
};
/**
* Assumed this can be called only when settings and filters are initialised
*/
export const updateHistorySearchFilters = (
exploreId: ExploreId,
filters: RichHistorySearchFilters
): ThunkResult<void> => {
return async (dispatch, getState) => {
// TODO: #45379 get new rich history list based on filters
dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters }));
const currentSettings = getState().explore.richHistorySettings!;
dispatch(
updateHistorySettings({
...currentSettings,
lastUsedDatasourceFilters: filters.datasourceFilters,
})
);
};
};
export const historyReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => { export const historyReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
if (historyUpdatedAction.match(action)) { if (historyUpdatedAction.match(action)) {
return { return {

View File

@ -9,6 +9,7 @@ import { getUrlStateFromPaneState, makeExplorePaneState } from './utils';
import { ThunkResult } from '../../../types'; import { ThunkResult } from '../../../types';
import { TimeSrv } from '../../dashboard/services/TimeSrv'; import { TimeSrv } from '../../dashboard/services/TimeSrv';
import { PanelModel } from 'app/features/dashboard/state'; import { PanelModel } from 'app/features/dashboard/state';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
// //
// Actions and Payloads // Actions and Payloads
@ -24,6 +25,12 @@ export const richHistoryUpdatedAction =
export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction'); export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction');
export const richHistoryLimitExceededAction = createAction('explore/richHistoryLimitExceededAction'); export const richHistoryLimitExceededAction = createAction('explore/richHistoryLimitExceededAction');
export const richHistorySettingsUpdatedAction = createAction<RichHistorySettings>('explore/richHistorySettingsUpdated');
export const richHistorySearchFiltersUpdatedAction = createAction<{
exploreId: ExploreId;
filters: RichHistorySearchFilters;
}>('explore/richHistorySearchFiltersUpdatedAction');
/** /**
* Resets state for explore. * Resets state for explore.
*/ */
@ -243,6 +250,14 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
}; };
} }
if (richHistorySettingsUpdatedAction.match(action)) {
const richHistorySettings = action.payload;
return {
...state,
richHistorySettings,
};
}
if (action.payload) { if (action.payload) {
const { exploreId } = action.payload; const { exploreId } = action.payload;
if (exploreId !== undefined) { if (exploreId !== undefined) {

View File

@ -15,6 +15,7 @@ import {
DataQueryResponse, DataQueryResponse,
ExplorePanelsState, ExplorePanelsState,
} from '@grafana/data'; } from '@grafana/data';
import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes';
export enum ExploreId { export enum ExploreId {
left = 'left', left = 'left',
@ -43,6 +44,11 @@ export interface ExploreState {
*/ */
right?: ExploreItemState; right?: ExploreItemState;
/**
* Settings for rich history (note: filters are stored per each pane separately)
*/
richHistorySettings?: RichHistorySettings;
/** /**
* True if local storage quota was exceeded when a rich history item was added. This is to prevent showing * True if local storage quota was exceeded when a rich history item was added. This is to prevent showing
* multiple errors when local storage is full. * multiple errors when local storage is full.
@ -153,6 +159,7 @@ export interface ExploreItemState {
* History of all queries * History of all queries
*/ */
richHistory: RichHistoryQuery[]; richHistory: RichHistoryQuery[];
richHistorySearchFilters?: RichHistorySearchFilters;
/** /**
* We are using caching to store query responses of queries run from logs navigation. * We are using caching to store query responses of queries run from logs navigation.