Query History: Move filtering to service layer (#48008)

* 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

* Move filtering to persistence layer

* Move filtering to persistence layer

* Simplify applying filters

* Split applying filters and reloading the history

* Debounce updating filters

* Update tests

* Fix waiting for debounced results

* Clear results when switching tabs

* Improve test coverage

* Update docs

* Revert extra handling for uid (will be added when we introduce remote storage)

* Fix betterer conflicts

* Fix imports

* Fix imports

* Simplify test setup

* Simplify assertion

* Improve readability

* Remove unnecessary casting

* Mock backend in integration tests
This commit is contained in:
Piotr Jamróz 2022-04-27 15:07:44 +02:00 committed by GitHub
parent a320e942a6
commit 361cc18b45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 442 additions and 339 deletions

View File

@ -230,7 +230,7 @@ exports[`no enzyme tests`] = {
"public/app/features/explore/LiveLogs.test.tsx:156663779": [ "public/app/features/explore/LiveLogs.test.tsx:156663779": [
[0, 17, 13, "RegExp match", "2409514259"] [0, 17, 13, "RegExp match", "2409514259"]
], ],
"public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:3328200031": [ "public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:3933225580": [
[0, 17, 13, "RegExp match", "2409514259"] [0, 17, 13, "RegExp match", "2409514259"]
], ],
"public/app/features/explore/RunButton.test.tsx:4267530266": [ "public/app/features/explore/RunButton.test.tsx:4267530266": [

View File

@ -4,7 +4,7 @@ import store from 'app/core/store';
import { afterEach, beforeEach } from '../../../test/lib/common'; import { afterEach, beforeEach } from '../../../test/lib/common';
import { RichHistoryQuery } from '../../types'; import { RichHistoryQuery } from '../../types';
import { backendSrv } from '../services/backend_srv'; import { backendSrv } from '../services/backend_srv';
import { RichHistorySettings } from '../utils/richHistoryTypes'; import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from '../utils/richHistoryTypes';
import RichHistoryLocalStorage, { MAX_HISTORY_ITEMS } from './RichHistoryLocalStorage'; import RichHistoryLocalStorage, { MAX_HISTORY_ITEMS } from './RichHistoryLocalStorage';
import { RichHistoryStorageWarning } from './RichHistoryStorage'; import { RichHistoryStorageWarning } from './RichHistoryStorage';
@ -12,7 +12,7 @@ import { RichHistoryStorageWarning } from './RichHistoryStorage';
const key = 'grafana.explore.richHistory'; const key = 'grafana.explore.richHistory';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object), ...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv, getBackendSrv: () => backendSrv,
getDataSourceSrv: () => { getDataSourceSrv: () => {
return { return {
@ -30,6 +30,15 @@ interface MockQuery extends DataQuery {
query: string; query: string;
} }
const mockFilters: RichHistorySearchFilters = {
search: '',
sortOrder: SortOrder.Descending,
datasourceFilters: [],
from: 0,
to: 7,
starred: false,
};
const mockItem: RichHistoryQuery<MockQuery> = { const mockItem: RichHistoryQuery<MockQuery> = {
id: '2', id: '2',
createdAt: 2, createdAt: 2,
@ -53,26 +62,28 @@ const mockItem2: RichHistoryQuery<MockQuery> = {
describe('RichHistoryLocalStorage', () => { describe('RichHistoryLocalStorage', () => {
let storage: RichHistoryLocalStorage; let storage: RichHistoryLocalStorage;
let now: Date;
let old: Date;
beforeEach(async () => { beforeEach(async () => {
now = new Date(1970, 0, 1);
old = new Date(1969, 0, 1);
jest.useFakeTimers('modern');
jest.setSystemTime(now);
storage = new RichHistoryLocalStorage(); storage = new RichHistoryLocalStorage();
await storage.deleteAll(); await storage.deleteAll();
}); });
afterEach(() => {
jest.useRealTimers();
});
describe('basic api', () => { describe('basic api', () => {
let dateSpy: jest.SpyInstance;
beforeEach(() => {
dateSpy = jest.spyOn(Date, 'now').mockImplementation(() => 2);
});
afterEach(() => {
dateSpy.mockRestore();
});
it('should save query history to localStorage', async () => { it('should save query history to localStorage', async () => {
await storage.addToRichHistory(mockItem); await storage.addToRichHistory(mockItem);
expect(store.exists(key)).toBeTruthy(); expect(store.exists(key)).toBeTruthy();
expect(await storage.getRichHistory()).toMatchObject([mockItem]); expect(await storage.getRichHistory(mockFilters)).toMatchObject([mockItem]);
}); });
it('should not save duplicated query to localStorage', async () => { it('should not save duplicated query to localStorage', async () => {
@ -81,25 +92,25 @@ describe('RichHistoryLocalStorage', () => {
await expect(async () => { await expect(async () => {
await storage.addToRichHistory(mockItem2); await storage.addToRichHistory(mockItem2);
}).rejects.toThrow('Entry already exists'); }).rejects.toThrow('Entry already exists');
expect(await storage.getRichHistory()).toMatchObject([mockItem2, mockItem]); expect(await storage.getRichHistory(mockFilters)).toMatchObject([mockItem2, mockItem]);
}); });
it('should update starred in localStorage', async () => { it('should update starred in localStorage', async () => {
await storage.addToRichHistory(mockItem); await storage.addToRichHistory(mockItem);
await storage.updateStarred(mockItem.id, false); await storage.updateStarred(mockItem.id, false);
expect((await storage.getRichHistory())[0].starred).toEqual(false); expect((await storage.getRichHistory(mockFilters))[0].starred).toEqual(false);
}); });
it('should update comment in localStorage', async () => { it('should update comment in localStorage', async () => {
await storage.addToRichHistory(mockItem); await storage.addToRichHistory(mockItem);
await storage.updateComment(mockItem.id, 'new comment'); await storage.updateComment(mockItem.id, 'new comment');
expect((await storage.getRichHistory())[0].comment).toEqual('new comment'); expect((await storage.getRichHistory(mockFilters))[0].comment).toEqual('new comment');
}); });
it('should delete query in localStorage', async () => { it('should delete query in localStorage', async () => {
await storage.addToRichHistory(mockItem); await storage.addToRichHistory(mockItem);
await storage.deleteRichHistory(mockItem.id); await storage.deleteRichHistory(mockItem.id);
expect(await storage.getRichHistory()).toEqual([]); expect(await storage.getRichHistory(mockFilters)).toEqual([]);
expect(store.getObject(key)).toEqual([]); expect(store.getObject(key)).toEqual([]);
}); });
@ -108,7 +119,7 @@ describe('RichHistoryLocalStorage', () => {
retentionPeriod: 2, retentionPeriod: 2,
starredTabAsFirstTab: true, starredTabAsFirstTab: true,
activeDatasourceOnly: true, activeDatasourceOnly: true,
lastUsedDatasourceFilters: [{ value: 'foobar' }], lastUsedDatasourceFilters: ['foobar'],
}; };
await storage.updateSettings(settings); await storage.updateSettings(settings);
const storageSettings = storage.getSettings(); const storageSettings = storage.getSettings();
@ -119,23 +130,35 @@ describe('RichHistoryLocalStorage', () => {
describe('retention policy and max limits', () => { describe('retention policy and max limits', () => {
it('should clear old not-starred items', async () => { it('should clear old not-starred items', async () => {
const now = Date.now(); const historyStarredOld = { starred: true, ts: old.getTime(), queries: [], comment: 'old starred' };
const history = [ const historyNotStarredOld = { starred: false, ts: old.getTime(), queries: [], comment: 'new not starred' };
{ starred: true, ts: 0, queries: [] }, const historyStarredNew = { starred: true, ts: now.getTime(), queries: [], comment: 'new starred' };
{ starred: true, ts: now, queries: [] }, const historyNotStarredNew = { starred: false, ts: now.getTime(), queries: [], comment: 'new not starred' };
{ starred: false, ts: 0, queries: [] }, const history = [historyNotStarredNew, historyStarredNew, historyStarredOld, historyNotStarredOld];
{ starred: false, ts: now, queries: [] },
];
store.setObject(key, history); store.setObject(key, history);
await storage.addToRichHistory(mockItem); const historyNew = {
const richHistory = await storage.getRichHistory(); starred: true,
datasourceUid: 'dev-test-uid',
datasourceName: 'dev-test',
comment: 'recently added',
queries: [{ refId: 'ref' }],
};
await storage.addToRichHistory(historyNew);
const richHistory = await storage.getRichHistory({
search: '',
sortOrder: SortOrder.Descending,
datasourceFilters: [],
from: 0,
to: 1000, // 1000 days: use a filter that is beyond retention policy to check old items were removed correctly
starred: false,
});
expect(richHistory).toMatchObject([ expect(richHistory).toMatchObject([
mockItem, expect.objectContaining({ comment: 'recently added' }),
{ starred: true, createdAt: 0, queries: [] }, expect.objectContaining({ comment: 'new not starred' }),
{ starred: true, createdAt: now, queries: [] }, expect.objectContaining({ comment: 'new starred' }),
{ starred: false, createdAt: now, queries: [] }, expect.objectContaining({ comment: 'old starred' }),
]); ]);
}); });
@ -214,7 +237,7 @@ describe('RichHistoryLocalStorage', () => {
], ],
}; };
const result = await storage.getRichHistory(); const result = await storage.getRichHistory(mockFilters);
expect(result).toStrictEqual([expectedHistoryItem]); expect(result).toStrictEqual([expectedHistoryItem]);
}); });
@ -249,7 +272,7 @@ describe('RichHistoryLocalStorage', () => {
], ],
}; };
const result = await storage.getRichHistory(); const result = await storage.getRichHistory(mockFilters);
expect(result).toStrictEqual([expectedHistoryItem]); expect(result).toStrictEqual([expectedHistoryItem]);
}); });
}); });

View File

@ -1,14 +1,18 @@
import { find, isEqual, omit } from 'lodash'; import { find, isEqual, omit } from 'lodash';
import { DataQuery } from '@grafana/data'; import { DataQuery, SelectableValue } from '@grafana/data';
import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory';
import { RichHistoryQuery } from '../../types'; import { RichHistoryQuery } from '../../types';
import store from '../store'; import store from '../store';
import { RichHistorySettings } from '../utils/richHistoryTypes';
import RichHistoryStorage, { RichHistoryServiceError, RichHistoryStorageWarning } from './RichHistoryStorage'; import RichHistoryStorage, { RichHistoryServiceError, RichHistoryStorageWarning } from './RichHistoryStorage';
import { fromDTO, toDTO } from './localStorageConverter'; import { fromDTO, toDTO } from './localStorageConverter';
import { createRetentionPeriodBoundary, RICH_HISTORY_SETTING_KEYS } from './richHistoryLocalStorageUtils'; import {
createRetentionPeriodBoundary,
filterAndSortQueries,
RICH_HISTORY_SETTING_KEYS,
} from './richHistoryLocalStorageUtils';
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;
@ -27,10 +31,16 @@ export type RichHistoryLocalStorageDTO = {
*/ */
export default class RichHistoryLocalStorage implements RichHistoryStorage { export default class RichHistoryLocalStorage implements RichHistoryStorage {
/** /**
* Return all history entries, perform migration and clean up entries not matching retention policy. * Return history entries based on provided filters, perform migration and clean up entries not matching retention policy.
*/ */
async getRichHistory() { async getRichHistory(filters: RichHistorySearchFilters) {
return getRichHistoryDTOs().map(fromDTO); const allQueries = getRichHistoryDTOs().map(fromDTO);
const queries = filters.starred ? allQueries.filter((q) => q.starred === true) : allQueries;
return filterAndSortQueries(queries, filters.sortOrder, filters.datasourceFilters, filters.search, [
filters.from,
filters.to,
]);
} }
async addToRichHistory(newRichHistoryQuery: Omit<RichHistoryQuery, 'id' | 'createdAt'>) { async addToRichHistory(newRichHistoryQuery: Omit<RichHistoryQuery, 'id' | 'createdAt'>) {
@ -111,7 +121,9 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
activeDatasourceOnly: store.getObject(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, false), activeDatasourceOnly: store.getObject(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, false),
retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7), retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7),
starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false), starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false),
lastUsedDatasourceFilters: store.getObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, []), lastUsedDatasourceFilters: store
.getObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, [])
.map((selectableValue: SelectableValue) => selectableValue.value),
}; };
} }
@ -119,7 +131,12 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
store.set(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, settings.activeDatasourceOnly); store.set(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, settings.activeDatasourceOnly);
store.set(RICH_HISTORY_SETTING_KEYS.retentionPeriod, settings.retentionPeriod); store.set(RICH_HISTORY_SETTING_KEYS.retentionPeriod, settings.retentionPeriod);
store.set(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, settings.starredTabAsFirstTab); store.set(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, settings.starredTabAsFirstTab);
store.setObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, settings.lastUsedDatasourceFilters); store.setObject(
RICH_HISTORY_SETTING_KEYS.datasourceFilters,
(settings.lastUsedDatasourceFilters || []).map((datasourceName: string) => {
return { value: datasourceName };
})
);
} }
} }

View File

@ -1,5 +1,6 @@
import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory';
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.
@ -32,7 +33,7 @@ export type RichHistoryStorageWarningDetails = {
* @alpha * @alpha
*/ */
export default interface RichHistoryStorage { export default interface RichHistoryStorage {
getRichHistory(): Promise<RichHistoryQuery[]>; getRichHistory(filters: RichHistorySearchFilters): Promise<RichHistoryQuery[]>;
/** /**
* Creates new RichHistoryQuery, returns object with unique id and created date * Creates new RichHistoryQuery, returns object with unique id and created date

View File

@ -5,7 +5,7 @@ import { RichHistoryLocalStorageDTO } from './RichHistoryLocalStorage';
import { fromDTO, toDTO } from './localStorageConverter'; import { fromDTO, toDTO } from './localStorageConverter';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object), ...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv, getBackendSrv: () => backendSrv,
getDataSourceSrv: () => { getDataSourceSrv: () => {
return { return {

View File

@ -0,0 +1,69 @@
import { DataQuery } from '@grafana/data';
import { SortOrder } from 'app/core/utils/richHistory';
import { RichHistoryQuery } from '../../types';
import { filterAndSortQueries } from './richHistoryLocalStorageUtils';
interface MockQuery extends DataQuery {
expr: string;
maxLines?: number | null;
}
const storedHistory: Array<RichHistoryQuery<MockQuery>> = [
{
id: '1',
createdAt: 1,
comment: '',
datasourceUid: 'datasource uid',
datasourceName: 'datasource history name',
queries: [
{ expr: 'query1', maxLines: null, refId: '1' },
{ expr: 'query2', refId: '2' },
],
starred: true,
},
{
id: '2',
createdAt: 2,
comment: 'comment 2',
datasourceUid: 'datasource uid 2',
datasourceName: 'datasource history name 2',
queries: [
{ expr: 'query3', maxLines: null, refId: '1' },
{ expr: 'query4', refId: '2' },
],
starred: true,
},
];
describe('filterQueries', () => {
it('should include all entries for empty filters', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, [], '');
expect(filteredQueries).toMatchObject([expect.objectContaining({ id: '1' }), expect.objectContaining({ id: '2' })]);
});
it('should sort entries based on the filter', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Descending, [], '');
expect(filteredQueries).toMatchObject([expect.objectContaining({ id: '2' }), expect.objectContaining({ id: '1' })]);
});
it('should filter out queries based on data source filter', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, ['not provided data source'], '');
expect(filteredQueries).toHaveLength(0);
});
it('should keep queries based on data source filter', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, ['datasource history name'], '');
expect(filteredQueries).toMatchObject([expect.objectContaining({ id: '1' })]);
});
it('should filter out all queries based on search filter', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, [], 'i do not exist in query');
expect(filteredQueries).toHaveLength(0);
});
it('should include queries based on search filter', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, [], 'query1');
expect(filteredQueries).toMatchObject([expect.objectContaining({ id: '1' })]);
});
it('should include queries based on comments', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, [], 'comment 2');
expect(filteredQueries).toMatchObject([expect.objectContaining({ id: '2' })]);
});
});

View File

@ -9,6 +9,22 @@ import { SortOrder } from '../utils/richHistoryTypes';
* Should be migrated to RichHistoryLocalStorage.ts * Should be migrated to RichHistoryLocalStorage.ts
*/ */
export function filterAndSortQueries(
queries: RichHistoryQuery[],
sortOrder: SortOrder,
listOfDatasourceFilters: string[],
searchFilter: string,
timeFilter?: [number, number]
) {
const filteredQueriesByDs = filterQueriesByDataSource(queries, listOfDatasourceFilters);
const filteredQueriesByDsAndSearchFilter = filterQueriesBySearchFilter(filteredQueriesByDs, searchFilter);
const filteredQueriesToBeSorted = timeFilter
? filterQueriesByTime(filteredQueriesByDsAndSearchFilter, timeFilter)
: filteredQueriesByDsAndSearchFilter;
return sortQueries(filteredQueriesToBeSorted, sortOrder);
}
export const createRetentionPeriodBoundary = (days: number, isLastTs: boolean) => { export const createRetentionPeriodBoundary = (days: number, isLastTs: boolean) => {
const today = new Date(); const today = new Date();
const date = new Date(today.setDate(today.getDate() - days)); const date = new Date(today.setDate(today.getDate() - days));
@ -21,19 +37,19 @@ export const createRetentionPeriodBoundary = (days: number, isLastTs: boolean) =
return boundary; return boundary;
}; };
export function filterQueriesByTime(queries: RichHistoryQuery[], timeFilter: [number, number]) { function filterQueriesByTime(queries: RichHistoryQuery[], timeFilter: [number, number]) {
const filter1 = createRetentionPeriodBoundary(timeFilter[0], true); // probably the vars should have a different name const filter1 = createRetentionPeriodBoundary(timeFilter[0], true); // probably the vars should have a different name
const filter2 = createRetentionPeriodBoundary(timeFilter[1], false); const filter2 = createRetentionPeriodBoundary(timeFilter[1], false);
return queries.filter((q) => q.createdAt < filter1 && q.createdAt > filter2); return queries.filter((q) => q.createdAt < filter1 && q.createdAt > filter2);
} }
export function filterQueriesByDataSource(queries: RichHistoryQuery[], listOfDatasourceFilters: string[]) { function filterQueriesByDataSource(queries: RichHistoryQuery[], listOfDatasourceFilters: string[]) {
return listOfDatasourceFilters.length > 0 return listOfDatasourceFilters.length > 0
? queries.filter((q) => listOfDatasourceFilters.includes(q.datasourceName)) ? queries.filter((q) => listOfDatasourceFilters.includes(q.datasourceName))
: queries; : queries;
} }
export function filterQueriesBySearchFilter(queries: RichHistoryQuery[], searchFilter: string) { function filterQueriesBySearchFilter(queries: RichHistoryQuery[], searchFilter: string) {
return queries.filter((query) => { return queries.filter((query) => {
if (query.comment.includes(searchFilter)) { if (query.comment.includes(searchFilter)) {
return true; return true;

View File

@ -13,7 +13,6 @@ import {
createQueryHeading, createQueryHeading,
deleteAllFromRichHistory, deleteAllFromRichHistory,
deleteQueryInRichHistory, deleteQueryInRichHistory,
filterAndSortQueries,
SortOrder, SortOrder,
} from './richHistory'; } from './richHistory';
@ -178,30 +177,6 @@ describe('richHistory', () => {
}); });
}); });
describe('filterQueries', () => {
it('should filter out queries based on data source filter', () => {
const filteredQueries = filterAndSortQueries(
storedHistory,
SortOrder.Ascending,
['not provided data source'],
''
);
expect(filteredQueries).toHaveLength(0);
});
it('should keep queries based on data source filter', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, ['datasource history name'], '');
expect(filteredQueries).toHaveLength(1);
});
it('should filter out all queries based on search filter', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, [], 'i do not exist in query');
expect(filteredQueries).toHaveLength(0);
});
it('should include queries based on search filter', () => {
const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, [], 'query1');
expect(filteredQueries).toHaveLength(1);
});
});
describe('createQueryHeading', () => { describe('createQueryHeading', () => {
it('should correctly create heading for queries when sort order is ascending ', () => { it('should correctly create heading for queries when sort order is ascending ', () => {
// Have to offset the timezone of a 1 microsecond epoch, and then reverse the changes // Have to offset the timezone of a 1 microsecond epoch, and then reverse the changes

View File

@ -5,12 +5,6 @@ import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { notifyApp } from 'app/core/actions'; import { notifyApp } from 'app/core/actions';
import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification'; import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification';
import {
filterQueriesByDataSource,
filterQueriesBySearchFilter,
filterQueriesByTime,
sortQueries,
} from 'app/core/history/richHistoryLocalStorageUtils';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
import { RichHistoryQuery } from 'app/types/explore'; import { RichHistoryQuery } from 'app/types/explore';
@ -21,9 +15,9 @@ import {
} from '../history/RichHistoryStorage'; } from '../history/RichHistoryStorage';
import { getRichHistoryStorage } from '../history/richHistoryStorageProvider'; import { getRichHistoryStorage } from '../history/richHistoryStorageProvider';
import { RichHistorySettings, SortOrder } from './richHistoryTypes'; import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from './richHistoryTypes';
export { SortOrder }; export { RichHistorySearchFilters, RichHistorySettings, SortOrder };
/* /*
* Add queries to rich history. Save only queries within the retention period, or that are starred. * Add queries to rich history. Save only queries within the retention period, or that are starred.
@ -80,8 +74,8 @@ export async function addToRichHistory(
return {}; return {};
} }
export async function getRichHistory(): Promise<RichHistoryQuery[]> { export async function getRichHistory(filters: RichHistorySearchFilters): Promise<RichHistoryQuery[]> {
return await getRichHistoryStorage().getRichHistory(); return await getRichHistoryStorage().getRichHistory(filters);
} }
export async function updateRichHistorySettings(settings: RichHistorySettings): Promise<void> { export async function updateRichHistorySettings(settings: RichHistorySettings): Promise<void> {
@ -124,22 +118,6 @@ export async function deleteQueryInRichHistory(id: string) {
} }
} }
export function filterAndSortQueries(
queries: RichHistoryQuery[],
sortOrder: SortOrder,
listOfDatasourceFilters: string[],
searchFilter: string,
timeFilter?: [number, number]
) {
const filteredQueriesByDs = filterQueriesByDataSource(queries, listOfDatasourceFilters);
const filteredQueriesByDsAndSearchFilter = filterQueriesBySearchFilter(filteredQueriesByDs, searchFilter);
const filteredQueriesToBeSorted = timeFilter
? filterQueriesByTime(filteredQueriesByDsAndSearchFilter, timeFilter)
: filteredQueriesByDsAndSearchFilter;
return sortQueries(filteredQueriesToBeSorted, sortOrder);
}
export const createUrlFromRichHistory = (query: RichHistoryQuery) => { export const createUrlFromRichHistory = (query: RichHistoryQuery) => {
const exploreState: ExploreUrlState = { const exploreState: ExploreUrlState = {
/* Default range, as we are not saving timerange in rich history */ /* Default range, as we are not saving timerange in rich history */
@ -229,31 +207,19 @@ export function mapQueriesToHeadings(query: RichHistoryQuery[], sortOrder: SortO
return mappedQueriesToHeadings; 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. * Create a list of all available data sources
*/ */
export function createDatasourcesList(queriesDatasources: string[]) { export function createDatasourcesList() {
const datasources: Array<{ label: string; value: string; imgUrl: string; isRemoved: boolean }> = []; return getDataSourceSrv()
.getList()
queriesDatasources.forEach((dsName) => { .map((dsSettings) => {
const dsSettings = getDataSourceSrv().getInstanceSettings(dsName); return {
if (dsSettings) { name: dsSettings.name,
datasources.push({ uid: dsSettings.uid,
label: dsSettings.name,
value: dsSettings.name,
imgUrl: dsSettings.meta.info.logos.small, imgUrl: dsSettings.meta.info.logos.small,
isRemoved: false, };
}); });
} else {
datasources.push({
label: dsName,
value: dsName,
imgUrl: 'public/img/icn-datasource.svg',
isRemoved: true,
});
}
});
return datasources;
} }
export function notEmptyQuery(query: DataQuery) { export function notEmptyQuery(query: DataQuery) {

View File

@ -1,5 +1,3 @@
import { SelectableValue } from '@grafana/data';
export enum SortOrder { export enum SortOrder {
Descending = 'Descending', Descending = 'Descending',
Ascending = 'Ascending', Ascending = 'Ascending',
@ -11,13 +9,14 @@ export interface RichHistorySettings {
retentionPeriod: number; retentionPeriod: number;
starredTabAsFirstTab: boolean; starredTabAsFirstTab: boolean;
activeDatasourceOnly: boolean; activeDatasourceOnly: boolean;
lastUsedDatasourceFilters: SelectableValue[]; lastUsedDatasourceFilters: string[];
} }
export type RichHistorySearchFilters = { export type RichHistorySearchFilters = {
search: string; search: string;
sortOrder: SortOrder; sortOrder: SortOrder;
datasourceFilters: SelectableValue[]; datasourceFilters: string[];
from: number; from: number;
to: number; to: number;
starred: boolean;
}; };

View File

@ -2,14 +2,25 @@ import { render, screen } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { SortOrder } from 'app/core/utils/richHistory';
import { SortOrder } from '../../../core/utils/richHistoryTypes';
import { ExploreId } from '../../../types/explore'; import { ExploreId } from '../../../types/explore';
import { RichHistory, RichHistoryProps, Tabs } from './RichHistory'; import { RichHistory, RichHistoryProps, Tabs } from './RichHistory';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() })); jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => {
return {
getList: () => {
return [];
},
};
},
}));
const setup = (propOverrides?: Partial<RichHistoryProps>) => { const setup = (propOverrides?: Partial<RichHistoryProps>) => {
const props: RichHistoryProps = { const props: RichHistoryProps = {
theme: {} as GrafanaTheme, theme: {} as GrafanaTheme,
@ -19,6 +30,8 @@ const setup = (propOverrides?: Partial<RichHistoryProps>) => {
richHistory: [], richHistory: [],
firstTab: Tabs.RichHistory, firstTab: Tabs.RichHistory,
deleteRichHistory: jest.fn(), deleteRichHistory: jest.fn(),
loadRichHistory: jest.fn(),
clearRichHistoryResults: jest.fn(),
onClose: jest.fn(), onClose: jest.fn(),
richHistorySearchFilters: { richHistorySearchFilters: {
search: '', search: '',
@ -26,6 +39,7 @@ const setup = (propOverrides?: Partial<RichHistoryProps>) => {
datasourceFilters: [], datasourceFilters: [],
from: 0, from: 0,
to: 7, to: 7,
starred: false,
}, },
richHistorySettings: { richHistorySettings: {
retentionPeriod: 0, retentionPeriod: 0,

View File

@ -1,12 +1,11 @@
import { debounce } from 'lodash';
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui'; import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui';
import { SortOrder } from 'app/core/utils/richHistory'; import { SortOrder, RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory';
import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
import { RichHistoryQueriesTab } from './RichHistoryQueriesTab'; import { RichHistoryQueriesTab } from './RichHistoryQueriesTab';
import { RichHistorySettingsTab } from './RichHistorySettingsTab'; import { RichHistorySettingsTab } from './RichHistorySettingsTab';
import { RichHistoryStarredTab } from './RichHistoryStarredTab'; import { RichHistoryStarredTab } from './RichHistoryStarredTab';
@ -27,9 +26,11 @@ export const sortOrderOptions = [
export interface RichHistoryProps extends Themeable { export interface RichHistoryProps extends Themeable {
richHistory: RichHistoryQuery[]; richHistory: RichHistoryQuery[];
richHistorySettings: RichHistorySettings; richHistorySettings: RichHistorySettings;
richHistorySearchFilters: RichHistorySearchFilters; richHistorySearchFilters?: RichHistorySearchFilters;
updateHistorySettings: (settings: RichHistorySettings) => void; updateHistorySettings: (settings: RichHistorySettings) => void;
updateHistorySearchFilters: (exploreId: ExploreId, filters: RichHistorySearchFilters) => void; updateHistorySearchFilters: (exploreId: ExploreId, filters: RichHistorySearchFilters) => void;
loadRichHistory: (exploreId: ExploreId) => void;
clearRichHistoryResults: (exploreId: ExploreId) => void;
deleteRichHistory: () => void; deleteRichHistory: () => void;
activeDatasourceInstance?: string; activeDatasourceInstance?: string;
firstTab: Tabs; firstTab: Tabs;
@ -43,13 +44,23 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
this.props.updateHistorySettings({ ...this.props.richHistorySettings, ...settingsToUpdate }); this.props.updateHistorySettings({ ...this.props.richHistorySettings, ...settingsToUpdate });
}; };
updateFilters = (filtersToUpdate: Partial<RichHistorySearchFilters>) => { updateFilters = (filtersToUpdate?: Partial<RichHistorySearchFilters>) => {
this.props.updateHistorySearchFilters(this.props.exploreId, { const filters = {
...this.props.richHistorySearchFilters, ...this.props.richHistorySearchFilters!,
...filtersToUpdate, ...filtersToUpdate,
}); };
this.props.updateHistorySearchFilters(this.props.exploreId, filters);
this.loadRichHistory();
}; };
clearResults = () => {
this.props.clearRichHistoryResults(this.props.exploreId);
};
loadRichHistory = debounce(() => {
this.props.loadRichHistory(this.props.exploreId);
}, 300);
onChangeRetentionPeriod = (retentionPeriod: SelectableValue<number>) => { onChangeRetentionPeriod = (retentionPeriod: SelectableValue<number>) => {
if (retentionPeriod.value !== undefined) { if (retentionPeriod.value !== undefined) {
this.updateSettings({ retentionPeriod: retentionPeriod.value }); this.updateSettings({ retentionPeriod: retentionPeriod.value });
@ -62,39 +73,9 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
toggleActiveDatasourceOnly = () => toggleActiveDatasourceOnly = () =>
this.updateSettings({ activeDatasourceOnly: !this.props.richHistorySettings.activeDatasourceOnly }); this.updateSettings({ activeDatasourceOnly: !this.props.richHistorySettings.activeDatasourceOnly });
onSelectDatasourceFilters = (datasourceFilters: SelectableValue[]) => this.updateFilters({ datasourceFilters });
onChangeSortOrder = (sortOrder: SortOrder) => this.updateFilters({ sortOrder });
/* 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
* available for user.
*/
initFilters() {
if (this.props.richHistorySettings.activeDatasourceOnly && this.props.activeDatasourceInstance) {
this.onSelectDatasourceFilters([
{ label: this.props.activeDatasourceInstance, value: this.props.activeDatasourceInstance },
]);
}
}
componentDidMount() {
this.initFilters();
}
/**
* Updating filters on didMount and didUpdate because we don't know when activeDatasourceInstance is ready
*/
componentDidUpdate(prevProps: RichHistoryProps) {
if (this.props.activeDatasourceInstance !== prevProps.activeDatasourceInstance) {
this.initFilters();
}
}
render() { render() {
const { activeDatasourceOnly, retentionPeriod } = this.props.richHistorySettings; const { richHistory, height, exploreId, deleteRichHistory, onClose, firstTab, activeDatasourceInstance } =
const { datasourceFilters, sortOrder } = this.props.richHistorySearchFilters; this.props;
const { richHistory, height, exploreId, deleteRichHistory, onClose, firstTab } = this.props;
const QueriesTab: TabConfig = { const QueriesTab: TabConfig = {
label: 'Query history', label: 'Query history',
@ -102,12 +83,11 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
content: ( content: (
<RichHistoryQueriesTab <RichHistoryQueriesTab
queries={richHistory} queries={richHistory}
sortOrder={sortOrder} updateFilters={this.updateFilters}
datasourceFilters={datasourceFilters} clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)}
activeDatasourceOnly={activeDatasourceOnly} activeDatasourceInstance={activeDatasourceInstance}
retentionPeriod={retentionPeriod} richHistorySettings={this.props.richHistorySettings}
onChangeSortOrder={this.onChangeSortOrder} richHistorySearchFilters={this.props.richHistorySearchFilters}
onSelectDatasourceFilters={this.onSelectDatasourceFilters}
exploreId={exploreId} exploreId={exploreId}
height={height} height={height}
/> />
@ -121,11 +101,11 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
content: ( content: (
<RichHistoryStarredTab <RichHistoryStarredTab
queries={richHistory} queries={richHistory}
sortOrder={sortOrder} activeDatasourceInstance={activeDatasourceInstance}
datasourceFilters={datasourceFilters} updateFilters={this.updateFilters}
activeDatasourceOnly={activeDatasourceOnly} clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)}
onChangeSortOrder={this.onChangeSortOrder} richHistorySettings={this.props.richHistorySettings}
onSelectDatasourceFilters={this.onSelectDatasourceFilters} richHistorySearchFilters={this.props.richHistorySearchFilters}
exploreId={exploreId} exploreId={exploreId}
/> />
), ),

View File

@ -1,7 +1,8 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { SortOrder } from '../../../core/utils/richHistoryTypes'; import { SortOrder } from 'app/core/utils/richHistory';
import { ExploreId } from '../../../types/explore'; import { ExploreId } from '../../../types/explore';
import { Tabs } from './RichHistory'; import { Tabs } from './RichHistory';
@ -9,6 +10,15 @@ import { RichHistoryContainer, Props } from './RichHistoryContainer';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() })); jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => {
return {
getList: () => [],
};
},
}));
const setup = (propOverrides?: Partial<Props>) => { const setup = (propOverrides?: Partial<Props>) => {
const props: Props = { const props: Props = {
width: 500, width: 500,
@ -18,6 +28,8 @@ const setup = (propOverrides?: Partial<Props>) => {
firstTab: Tabs.RichHistory, firstTab: Tabs.RichHistory,
deleteRichHistory: jest.fn(), deleteRichHistory: jest.fn(),
initRichHistory: jest.fn(), initRichHistory: jest.fn(),
loadRichHistory: jest.fn(),
clearRichHistoryResults: jest.fn(),
updateHistorySearchFilters: jest.fn(), updateHistorySearchFilters: jest.fn(),
updateHistorySettings: jest.fn(), updateHistorySettings: jest.fn(),
onClose: jest.fn(), onClose: jest.fn(),
@ -27,6 +39,7 @@ const setup = (propOverrides?: Partial<Props>) => {
datasourceFilters: [], datasourceFilters: [],
from: 0, from: 0,
to: 7, to: 7,
starred: false,
}, },
richHistorySettings: { richHistorySettings: {
retentionPeriod: 0, retentionPeriod: 0,
@ -42,8 +55,8 @@ const setup = (propOverrides?: Partial<Props>) => {
}; };
describe('RichHistoryContainer', () => { describe('RichHistoryContainer', () => {
it('should show loading message when settings and filters are not ready', () => { it('should show loading message when settings are not ready', () => {
const { container } = setup({ richHistorySearchFilters: undefined, richHistorySettings: undefined }); const { container } = setup({ richHistorySettings: undefined });
expect(container).toHaveTextContent('Loading...'); expect(container).toHaveTextContent('Loading...');
}); });
it('should render component with correct width', () => { it('should render component with correct width', () => {

View File

@ -12,6 +12,8 @@ import { ExploreDrawer } from '../ExploreDrawer';
import { import {
deleteRichHistory, deleteRichHistory,
initRichHistory, initRichHistory,
loadRichHistory,
clearRichHistoryResults,
updateHistorySettings, updateHistorySettings,
updateHistorySearchFilters, updateHistorySearchFilters,
} from '../state/history'; } from '../state/history';
@ -40,6 +42,8 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
const mapDispatchToProps = { const mapDispatchToProps = {
initRichHistory, initRichHistory,
loadRichHistory,
clearRichHistoryResults,
updateHistorySettings, updateHistorySettings,
updateHistorySearchFilters, updateHistorySearchFilters,
deleteRichHistory, deleteRichHistory,
@ -66,6 +70,8 @@ export function RichHistoryContainer(props: Props) {
exploreId, exploreId,
deleteRichHistory, deleteRichHistory,
initRichHistory, initRichHistory,
loadRichHistory,
clearRichHistoryResults,
richHistorySettings, richHistorySettings,
updateHistorySettings, updateHistorySettings,
richHistorySearchFilters, richHistorySearchFilters,
@ -74,10 +80,10 @@ export function RichHistoryContainer(props: Props) {
} = props; } = props;
useEffect(() => { useEffect(() => {
initRichHistory(exploreId); initRichHistory();
}, [initRichHistory, exploreId]); }, [initRichHistory]);
if (!richHistorySettings || !richHistorySearchFilters) { if (!richHistorySettings) {
return <span>Loading...</span>; return <span>Loading...</span>;
} }
@ -100,6 +106,8 @@ export function RichHistoryContainer(props: Props) {
richHistorySearchFilters={richHistorySearchFilters} richHistorySearchFilters={richHistorySearchFilters}
updateHistorySettings={updateHistorySettings} updateHistorySettings={updateHistorySettings}
updateHistorySearchFilters={updateHistorySearchFilters} updateHistorySearchFilters={updateHistorySearchFilters}
loadRichHistory={loadRichHistory}
clearRichHistoryResults={clearRichHistoryResults}
/> />
</ExploreDrawer> </ExploreDrawer>
); );

View File

@ -1,32 +1,30 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { uniqBy } from 'lodash'; import React, { useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { useDebounce } from 'react-use';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { stylesFactory, useTheme, RangeSlider, MultiSelect, Select, FilterInput } from '@grafana/ui'; import { FilterInput, MultiSelect, RangeSlider, Select, stylesFactory, useTheme } from '@grafana/ui';
import { import {
SortOrder, createDatasourcesList,
mapNumbertoTimeInSlider, mapNumbertoTimeInSlider,
mapQueriesToHeadings, mapQueriesToHeadings,
createDatasourcesList, SortOrder,
filterAndSortQueries, RichHistorySearchFilters,
RichHistorySettings,
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { ExploreId, RichHistoryQuery } from 'app/types/explore';
import { sortOrderOptions } from './RichHistory'; import { sortOrderOptions } from './RichHistory';
import RichHistoryCard from './RichHistoryCard'; import RichHistoryCard from './RichHistoryCard';
export interface Props { export interface Props {
queries: RichHistoryQuery[]; queries: RichHistoryQuery[];
sortOrder: SortOrder; activeDatasourceInstance?: string;
activeDatasourceOnly: boolean; updateFilters: (filtersToUpdate?: Partial<RichHistorySearchFilters>) => void;
datasourceFilters: SelectableValue[]; clearRichHistoryResults: () => void;
retentionPeriod: number; richHistorySettings: RichHistorySettings;
richHistorySearchFilters?: RichHistorySearchFilters;
exploreId: ExploreId; exploreId: ExploreId;
height: number; height: number;
onChangeSortOrder: (sortOrder: SortOrder) => void;
onSelectDatasourceFilters: (value: SelectableValue[]) => void;
} }
const getStyles = stylesFactory((theme: GrafanaTheme, height: number) => { const getStyles = stylesFactory((theme: GrafanaTheme, height: number) => {
@ -120,107 +118,106 @@ const getStyles = stylesFactory((theme: GrafanaTheme, height: number) => {
export function RichHistoryQueriesTab(props: Props) { export function RichHistoryQueriesTab(props: Props) {
const { const {
datasourceFilters,
onSelectDatasourceFilters,
queries, queries,
onChangeSortOrder, richHistorySearchFilters,
sortOrder, updateFilters,
activeDatasourceOnly, clearRichHistoryResults,
retentionPeriod, richHistorySettings,
exploreId, exploreId,
height, height,
activeDatasourceInstance,
} = props; } = props;
const [timeFilter, setTimeFilter] = useState<[number, number]>([0, retentionPeriod]);
const [data, setData] = useState<[RichHistoryQuery[], ReturnType<typeof createDatasourcesList>]>([[], []]);
const [searchInput, setSearchInput] = useState('');
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme, height); const styles = getStyles(theme, height);
useDebounce( const listOfDatasources = createDatasourcesList();
() => {
setDebouncedSearchInput(searchInput);
},
300,
[searchInput]
);
useEffect(() => { useEffect(() => {
const datasourcesRetrievedFromQueryHistory = uniqBy(queries, 'datasourceName').map((d) => d.datasourceName); const datasourceFilters =
const listOfDatasources = createDatasourcesList(datasourcesRetrievedFromQueryHistory); richHistorySettings.activeDatasourceOnly && activeDatasourceInstance
? [activeDatasourceInstance]
: richHistorySettings.lastUsedDatasourceFilters;
const filters: RichHistorySearchFilters = {
search: '',
sortOrder: SortOrder.Descending,
datasourceFilters,
from: 0,
to: richHistorySettings.retentionPeriod,
starred: false,
};
updateFilters(filters);
setData([ return () => {
filterAndSortQueries( clearRichHistoryResults();
queries, };
sortOrder, // eslint-disable-next-line react-hooks/exhaustive-deps
datasourceFilters.map((d) => d.value), }, []);
debouncedSearchInput,
timeFilter
),
listOfDatasources,
]);
}, [timeFilter, queries, sortOrder, datasourceFilters, debouncedSearchInput]);
const [filteredQueries, listOfDatasources] = data; if (!richHistorySearchFilters) {
return <span>Loading...</span>;
}
/* mappedQueriesToHeadings is an object where query headings (stringified dates/data sources) /* mappedQueriesToHeadings is an object where query headings (stringified dates/data sources)
* are keys and arrays with queries that belong to that headings are values. * are keys and arrays with queries that belong to that headings are values.
*/ */
const mappedQueriesToHeadings = mapQueriesToHeadings(filteredQueries, sortOrder); const mappedQueriesToHeadings = mapQueriesToHeadings(queries, richHistorySearchFilters.sortOrder);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.containerSlider}> <div className={styles.containerSlider}>
<div className={styles.slider}> <div className={styles.slider}>
<div className="label-slider">Filter history</div> <div className="label-slider">Filter history</div>
<div className="label-slider">{mapNumbertoTimeInSlider(timeFilter[0])}</div> <div className="label-slider">{mapNumbertoTimeInSlider(richHistorySearchFilters.from)}</div>
<div className="slider"> <div className="slider">
<RangeSlider <RangeSlider
tooltipAlwaysVisible={false} tooltipAlwaysVisible={false}
min={0} min={0}
max={retentionPeriod} max={richHistorySettings.retentionPeriod}
value={timeFilter} value={[richHistorySearchFilters.from, richHistorySearchFilters.to]}
orientation="vertical" orientation="vertical"
formatTooltipResult={mapNumbertoTimeInSlider} formatTooltipResult={mapNumbertoTimeInSlider}
reverse={true} reverse={true}
onAfterChange={setTimeFilter as () => number[]} onAfterChange={(value) => {
updateFilters({ from: value![0], to: value![1] });
}}
/> />
</div> </div>
<div className="label-slider">{mapNumbertoTimeInSlider(timeFilter[1])}</div> <div className="label-slider">{mapNumbertoTimeInSlider(richHistorySearchFilters.to)}</div>
</div> </div>
</div> </div>
<div className={styles.containerContent}> <div className={styles.containerContent}>
<div className={styles.selectors}> <div className={styles.selectors}>
{!activeDatasourceOnly && ( {!richHistorySettings.activeDatasourceOnly && (
<MultiSelect <MultiSelect
className={styles.multiselect} className={styles.multiselect}
menuShouldPortal menuShouldPortal
options={listOfDatasources} options={listOfDatasources.map((ds) => {
value={datasourceFilters} return { value: ds.name, label: ds.name };
})}
value={richHistorySearchFilters.datasourceFilters}
placeholder="Filter queries for data sources(s)" placeholder="Filter queries for data sources(s)"
aria-label="Filter queries for data sources(s)" aria-label="Filter queries for data sources(s)"
onChange={onSelectDatasourceFilters} onChange={(options: SelectableValue[]) => {
updateFilters({ datasourceFilters: options.map((option) => option.value) });
}}
/> />
)} )}
<div className={styles.filterInput}> <div className={styles.filterInput}>
<FilterInput <FilterInput
placeholder="Search queries" placeholder="Search queries"
value={searchInput} value={richHistorySearchFilters.search}
onChange={(value: string) => { onChange={(search: string) => updateFilters({ search })}
setSearchInput(value);
}}
/> />
</div> </div>
<div aria-label="Sort queries" className={styles.sort}> <div aria-label="Sort queries" className={styles.sort}>
<Select <Select
menuShouldPortal menuShouldPortal
value={sortOrderOptions.filter((order) => order.value === sortOrder)} value={sortOrderOptions.filter((order) => order.value === richHistorySearchFilters.sortOrder)}
options={sortOrderOptions} options={sortOrderOptions}
placeholder="Sort queries by" placeholder="Sort queries by"
onChange={(e) => onChangeSortOrder(e.value as SortOrder)} onChange={(e: SelectableValue<SortOrder>) => updateFilters({ sortOrder: e.value })}
/> />
</div> </div>
</div> </div>
@ -231,14 +228,14 @@ export function RichHistoryQueriesTab(props: Props) {
{heading} <span className={styles.queries}>{mappedQueriesToHeadings[heading].length} queries</span> {heading} <span className={styles.queries}>{mappedQueriesToHeadings[heading].length} queries</span>
</div> </div>
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => { {mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => {
const idx = listOfDatasources.findIndex((d) => d.label === q.datasourceName); const idx = listOfDatasources.findIndex((d) => d.name === q.datasourceName);
return ( return (
<RichHistoryCard <RichHistoryCard
query={q} query={q}
key={q.id} key={q.id}
exploreId={exploreId} exploreId={exploreId}
dsImg={listOfDatasources[idx].imgUrl} dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl}
isRemoved={listOfDatasources[idx].isRemoved} isRemoved={idx === -1}
/> />
); );
})} })}

View File

@ -9,19 +9,37 @@ import { RichHistoryStarredTab, Props } from './RichHistoryStarredTab';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() })); jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
const setup = (propOverrides?: Partial<Props>) => { jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => {
return {
getList: () => [],
};
},
}));
const setup = (activeDatasourceOnly = false) => {
const props: Props = { const props: Props = {
queries: [], queries: [],
sortOrder: SortOrder.Ascending, updateFilters: jest.fn(),
activeDatasourceOnly: false, clearRichHistoryResults: jest.fn(),
datasourceFilters: [],
exploreId: ExploreId.left, exploreId: ExploreId.left,
onChangeSortOrder: jest.fn(), richHistorySettings: {
onSelectDatasourceFilters: jest.fn(), retentionPeriod: 7,
starredTabAsFirstTab: false,
activeDatasourceOnly,
lastUsedDatasourceFilters: [],
},
richHistorySearchFilters: {
search: '',
sortOrder: SortOrder.Ascending,
datasourceFilters: [],
from: 0,
to: 7,
starred: false,
},
}; };
Object.assign(props, propOverrides);
const wrapper = mount(<RichHistoryStarredTab {...props} />); const wrapper = mount(<RichHistoryStarredTab {...props} />);
return wrapper; return wrapper;
}; };
@ -41,7 +59,7 @@ describe('RichHistoryStarredTab', () => {
}); });
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(true);
expect(wrapper.find({ 'aria-label': 'Filter queries for data sources(s)' }).exists()).toBeFalsy(); expect(wrapper.find({ 'aria-label': 'Filter queries for data sources(s)' }).exists()).toBeFalsy();
}); });
}); });

View File

@ -1,11 +1,14 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { uniqBy } from 'lodash'; import React, { useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { useDebounce } from 'react-use';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { stylesFactory, useTheme, Select, MultiSelect, FilterInput } from '@grafana/ui'; import { stylesFactory, useTheme, Select, MultiSelect, FilterInput } from '@grafana/ui';
import { filterAndSortQueries, createDatasourcesList, SortOrder } from 'app/core/utils/richHistory'; import {
createDatasourcesList,
SortOrder,
RichHistorySearchFilters,
RichHistorySettings,
} from 'app/core/utils/richHistory';
import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { sortOrderOptions } from './RichHistory'; import { sortOrderOptions } from './RichHistory';
@ -13,12 +16,12 @@ import RichHistoryCard from './RichHistoryCard';
export interface Props { export interface Props {
queries: RichHistoryQuery[]; queries: RichHistoryQuery[];
sortOrder: SortOrder; activeDatasourceInstance?: string;
activeDatasourceOnly: boolean; updateFilters: (filtersToUpdate: Partial<RichHistorySearchFilters>) => void;
datasourceFilters: SelectableValue[]; clearRichHistoryResults: () => void;
richHistorySearchFilters?: RichHistorySearchFilters;
richHistorySettings: RichHistorySettings;
exploreId: ExploreId; exploreId: ExploreId;
onChangeSortOrder: (sortOrder: SortOrder) => void;
onSelectDatasourceFilters: (value: SelectableValue[]) => void;
} }
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme) => {
@ -67,90 +70,89 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
export function RichHistoryStarredTab(props: Props) { export function RichHistoryStarredTab(props: Props) {
const { const {
datasourceFilters, updateFilters,
onSelectDatasourceFilters, clearRichHistoryResults,
activeDatasourceInstance,
richHistorySettings,
queries, queries,
onChangeSortOrder, richHistorySearchFilters,
sortOrder,
activeDatasourceOnly,
exploreId, exploreId,
} = props; } = props;
const [data, setData] = useState<[RichHistoryQuery[], ReturnType<typeof createDatasourcesList>]>([[], []]);
const [searchInput, setSearchInput] = useState('');
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme); const styles = getStyles(theme);
useDebounce( const listOfDatasources = createDatasourcesList();
() => {
setDebouncedSearchInput(searchInput);
},
300,
[searchInput]
);
useEffect(() => { useEffect(() => {
const datasourcesRetrievedFromQueryHistory = uniqBy(queries, 'datasourceName').map((d) => d.datasourceName); const datasourceFilters =
const listOfDatasources = createDatasourcesList(datasourcesRetrievedFromQueryHistory); richHistorySettings.activeDatasourceOnly && activeDatasourceInstance
const starredQueries = queries.filter((q) => q.starred === true); ? [activeDatasourceInstance]
setData([ : richHistorySettings.lastUsedDatasourceFilters;
filterAndSortQueries( const filters: RichHistorySearchFilters = {
starredQueries, search: '',
sortOrder, sortOrder: SortOrder.Descending,
datasourceFilters.map((d) => d.value), datasourceFilters,
debouncedSearchInput from: 0,
), to: richHistorySettings.retentionPeriod,
listOfDatasources, starred: true,
]); };
}, [queries, sortOrder, datasourceFilters, debouncedSearchInput]); updateFilters(filters);
return () => {
clearRichHistoryResults();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [filteredQueries, listOfDatasources] = data; if (!richHistorySearchFilters) {
return <span>Loading...</span>;
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.containerContent}> <div className={styles.containerContent}>
<div className={styles.selectors}> <div className={styles.selectors}>
{!activeDatasourceOnly && ( {!richHistorySettings.activeDatasourceOnly && (
<MultiSelect <MultiSelect
className={styles.multiselect} className={styles.multiselect}
menuShouldPortal menuShouldPortal
options={listOfDatasources} options={listOfDatasources.map((ds) => {
value={datasourceFilters} return { value: ds.name, label: ds.name };
})}
value={richHistorySearchFilters.datasourceFilters}
placeholder="Filter queries for data sources(s)" placeholder="Filter queries for data sources(s)"
aria-label="Filter queries for data sources(s)" aria-label="Filter queries for data sources(s)"
onChange={onSelectDatasourceFilters} onChange={(options: SelectableValue[]) => {
updateFilters({ datasourceFilters: options.map((option) => option.value) });
}}
/> />
)} )}
<div className={styles.filterInput}> <div className={styles.filterInput}>
<FilterInput <FilterInput
placeholder="Search queries" placeholder="Search queries"
value={searchInput} value={richHistorySearchFilters.search}
onChange={(value: string) => { onChange={(search: string) => updateFilters({ search })}
setSearchInput(value);
}}
/> />
</div> </div>
<div aria-label="Sort queries" className={styles.sort}> <div aria-label="Sort queries" className={styles.sort}>
<Select <Select
menuShouldPortal menuShouldPortal
value={sortOrderOptions.filter((order) => order.value === richHistorySearchFilters.sortOrder)}
options={sortOrderOptions} options={sortOrderOptions}
value={sortOrderOptions.filter((order) => order.value === sortOrder)}
placeholder="Sort queries by" placeholder="Sort queries by"
onChange={(e) => onChangeSortOrder(e.value as SortOrder)} onChange={(e: SelectableValue<SortOrder>) => updateFilters({ sortOrder: e.value })}
/> />
</div> </div>
</div> </div>
{filteredQueries.map((q) => { {queries.map((q) => {
const idx = listOfDatasources.findIndex((d) => d.label === q.datasourceName); const idx = listOfDatasources.findIndex((d) => d.name === q.datasourceName);
return ( return (
<RichHistoryCard <RichHistoryCard
query={q} query={q}
key={q.id} key={q.id}
exploreId={exploreId} exploreId={exploreId}
dsImg={listOfDatasources[idx].imgUrl} dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl}
isRemoved={listOfDatasources[idx].isRemoved} isRemoved={idx === -1}
/> />
); );
})} })}

View File

@ -4,10 +4,10 @@ import { ExploreId } from '../../../../types';
import { withinExplore } from './setup'; import { withinExplore } from './setup';
export const assertQueryHistoryExists = (query: string, exploreId: ExploreId = ExploreId.left) => { export const assertQueryHistoryExists = async (query: string, exploreId: ExploreId = ExploreId.left) => {
const selector = withinExplore(exploreId); const selector = withinExplore(exploreId);
expect(selector.getByText('1 queries')).toBeInTheDocument(); expect(await selector.findByText('1 queries')).toBeInTheDocument();
const queryItem = selector.getByLabelText('Query text'); const queryItem = selector.getByLabelText('Query text');
expect(queryItem).toHaveTextContent(query); expect(queryItem).toHaveTextContent(query);
}; };

View File

@ -8,6 +8,12 @@ import { LokiQuery } from '../../../plugins/datasource/loki/types';
import { makeLogsQueryResponse } from './helper/query'; import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, waitForExplore } from './helper/setup'; import { setupExplore, waitForExplore } from './helper/setup';
const fetch = jest.fn();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({ fetch }),
}));
jest.mock('react-virtualized-auto-sizer', () => { jest.mock('react-virtualized-auto-sizer', () => {
return { return {
__esModule: true, __esModule: true,

View File

@ -26,6 +26,12 @@ import {
import { makeLogsQueryResponse } from './helper/query'; import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, tearDown, waitForExplore } from './helper/setup'; import { setupExplore, tearDown, waitForExplore } from './helper/setup';
const fetch = jest.fn();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({ fetch }),
}));
jest.mock('react-virtualized-auto-sizer', () => { jest.mock('react-virtualized-auto-sizer', () => {
return { return {
__esModule: true, __esModule: true,

View File

@ -7,7 +7,6 @@ import {
deleteQueryInRichHistory, deleteQueryInRichHistory,
getRichHistory, getRichHistory,
getRichHistorySettings, getRichHistorySettings,
SortOrder,
updateCommentInRichHistory, updateCommentInRichHistory,
updateRichHistorySettings, updateRichHistorySettings,
updateStarredInRichHistory, updateStarredInRichHistory,
@ -118,39 +117,33 @@ export const deleteRichHistory = (): ThunkResult<void> => {
}; };
export const loadRichHistory = (exploreId: ExploreId): ThunkResult<void> => { export const loadRichHistory = (exploreId: ExploreId): ThunkResult<void> => {
return async (dispatch, getState) => {
const filters = getState().explore![exploreId]?.richHistorySearchFilters;
if (filters) {
const richHistory = await getRichHistory(filters);
dispatch(richHistoryUpdatedAction({ richHistory, exploreId }));
}
};
};
export const clearRichHistoryResults = (exploreId: ExploreId): ThunkResult<void> => {
return async (dispatch) => { return async (dispatch) => {
// TODO: #45379 pass currently applied search filters dispatch(richHistorySearchFiltersUpdatedAction({ filters: undefined, exploreId }));
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 * 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. * (but only once per session). Filters are initialised by the tab (starred or home).
*/ */
export const initRichHistory = (exploreId: ExploreId): ThunkResult<void> => { export const initRichHistory = (): ThunkResult<void> => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
let settings = getState().explore.richHistorySettings; let settings = getState().explore.richHistorySettings;
if (!settings) { if (!settings) {
settings = await getRichHistorySettings(); settings = await getRichHistorySettings();
dispatch(richHistorySettingsUpdatedAction(settings)); dispatch(richHistorySettingsUpdatedAction(settings));
} }
dispatch(
richHistorySearchFiltersUpdatedAction({
exploreId,
filters: {
search: '',
sortOrder: SortOrder.Descending,
datasourceFilters: settings!.lastUsedDatasourceFilters || [],
from: 0,
to: settings!.retentionPeriod,
},
})
);
dispatch(loadRichHistory(exploreId));
}; };
}; };
@ -169,10 +162,9 @@ export const updateHistorySearchFilters = (
filters: RichHistorySearchFilters filters: RichHistorySearchFilters
): ThunkResult<void> => { ): ThunkResult<void> => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
// TODO: #45379 get new rich history list based on filters await dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters: { ...filters } }));
dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters }));
const currentSettings = getState().explore.richHistorySettings!; const currentSettings = getState().explore.richHistorySettings!;
dispatch( await dispatch(
updateHistorySettings({ updateHistorySettings({
...currentSettings, ...currentSettings,
lastUsedDatasourceFilters: filters.datasourceFilters, lastUsedDatasourceFilters: filters.datasourceFilters,

View File

@ -31,7 +31,7 @@ export const richHistoryLimitExceededAction = createAction('explore/richHistoryL
export const richHistorySettingsUpdatedAction = createAction<RichHistorySettings>('explore/richHistorySettingsUpdated'); export const richHistorySettingsUpdatedAction = createAction<RichHistorySettings>('explore/richHistorySettingsUpdated');
export const richHistorySearchFiltersUpdatedAction = createAction<{ export const richHistorySearchFiltersUpdatedAction = createAction<{
exploreId: ExploreId; exploreId: ExploreId;
filters: RichHistorySearchFilters; filters?: RichHistorySearchFilters;
}>('explore/richHistorySearchFiltersUpdatedAction'); }>('explore/richHistorySearchFiltersUpdatedAction');
/** /**

View File

@ -323,7 +323,8 @@ async function handleHistory(
// Because filtering happens in the backend we cannot add a new entry without checking if it matches currently // Because filtering happens in the backend we cannot add a new entry without checking if it matches currently
// used filters. Instead, we refresh the query history list. // used filters. Instead, we refresh the query history list.
// TODO: run only if Query History list is opened (#47252) // TODO: run only if Query History list is opened (#47252)
dispatch(loadRichHistory(exploreId)); await dispatch(loadRichHistory(ExploreId.left));
await dispatch(loadRichHistory(ExploreId.right));
} }
/** /**