Query History: Results pagination (#49217)

* 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)

* Create basic plan

* Rename query history toggle

* Add list of supported features and add ds name to RichHistoryQuery object

* Clean up

Removed planned items will be addressed in upcoming prs (filtering and pagination)

* Handle data source filters

* Simplify DTO conversion

* Clean up

* Fix betterer conflicts

* Fix imports

* Fix imports

* Post-merge fixes

* Use config instead of a feature flag

* Use config instead of a feature flag

* Update converter tests

* Add tests for RichHistoryRemoteStorage

* Simplify test setup

* Simplify assertion

* Add e2e test for query history

* Remove duplicated entry

* Fix unit tests

* Improve readability

* Remove unnecessary casting

* Mock backend in integration tests

* Remove unnecessary casting

* Fix integration test

* Update betterer results

* Fix unit tests

* Simplify testing with DataSourceSrv

* Fix sorting and add to/from filtering

* Basic pagination

* Show load more only if there are items to load

* Post-merge fixes

* Change initial page limit to 100

* Fix unit tests

* Fix linting errors

* Test pagination

* Fix query migration

* Fix unit tests

* Fix prettier

* Remove full stop

* Do not show number of queries with partial results to avoid confusion

* Show number of displayed results when partial results are shown
pull/49266/head^2
Piotr Jamróz 3 years ago committed by GitHub
parent 349d9973de
commit 060f0e5633
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .betterer.results
  2. 22
      public/app/core/history/RichHistoryLocalStorage.test.ts
  3. 3
      public/app/core/history/RichHistoryLocalStorage.ts
  4. 13
      public/app/core/history/RichHistoryRemoteStorage.test.ts
  5. 13
      public/app/core/history/RichHistoryRemoteStorage.ts
  6. 4
      public/app/core/history/RichHistoryStorage.ts
  7. 4
      public/app/core/utils/richHistory.test.ts
  8. 5
      public/app/core/utils/richHistory.ts
  9. 1
      public/app/core/utils/richHistoryTypes.ts
  10. 2
      public/app/features/explore/RichHistory/RichHistory.test.tsx
  11. 19
      public/app/features/explore/RichHistory/RichHistory.tsx
  12. 2
      public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx
  13. 9
      public/app/features/explore/RichHistory/RichHistoryContainer.tsx
  14. 18
      public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx
  15. 2
      public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx
  16. 11
      public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx
  17. 14
      public/app/features/explore/spec/helper/assert.ts
  18. 5
      public/app/features/explore/spec/helper/interactions.ts
  19. 5
      public/app/features/explore/spec/helper/setup.tsx
  20. 33
      public/app/features/explore/spec/queryHistory.test.tsx
  21. 4
      public/app/features/explore/state/explorePane.ts
  22. 37
      public/app/features/explore/state/history.ts
  23. 5
      public/app/features/explore/state/main.ts
  24. 1
      public/app/types/explore.ts

@ -179,7 +179,7 @@ exports[`no enzyme tests`] = {
"public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.test.tsx:4164297658": [ "public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.test.tsx:4164297658": [
[0, 17, 13, "RegExp match", "2409514259"] [0, 17, 13, "RegExp match", "2409514259"]
], ],
"public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:3420464349": [ "public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:523695501": [
[0, 17, 13, "RegExp match", "2409514259"] [0, 17, 13, "RegExp match", "2409514259"]
], ],
"public/app/features/folders/FolderSettingsPage.test.tsx:1109052730": [ "public/app/features/folders/FolderSettingsPage.test.tsx:1109052730": [

@ -86,7 +86,7 @@ describe('RichHistoryLocalStorage', () => {
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(mockFilters)).toMatchObject([mockItem]); expect((await storage.getRichHistory(mockFilters)).richHistory).toMatchObject([mockItem]);
}); });
it('should not save duplicated query to localStorage', async () => { it('should not save duplicated query to localStorage', async () => {
@ -95,25 +95,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(mockFilters)).toMatchObject([mockItem2, mockItem]); expect((await storage.getRichHistory(mockFilters)).richHistory).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(mockFilters))[0].starred).toEqual(false); expect((await storage.getRichHistory(mockFilters)).richHistory[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(mockFilters))[0].comment).toEqual('new comment'); expect((await storage.getRichHistory(mockFilters)).richHistory[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(mockFilters)).toEqual([]); expect((await storage.getRichHistory(mockFilters)).richHistory).toEqual([]);
expect(store.getObject(key)).toEqual([]); expect(store.getObject(key)).toEqual([]);
}); });
@ -172,7 +172,7 @@ describe('RichHistoryLocalStorage', () => {
queries: [{ refId: 'ref' }], queries: [{ refId: 'ref' }],
}; };
await storage.addToRichHistory(historyNew); await storage.addToRichHistory(historyNew);
const richHistory = await storage.getRichHistory({ const { richHistory } = await storage.getRichHistory({
search: '', search: '',
sortOrder: SortOrder.Descending, sortOrder: SortOrder.Descending,
datasourceFilters: [], datasourceFilters: [],
@ -264,8 +264,9 @@ describe('RichHistoryLocalStorage', () => {
], ],
}; };
const result = await storage.getRichHistory(mockFilters); const { richHistory, total } = await storage.getRichHistory(mockFilters);
expect(result).toStrictEqual([expectedHistoryItem]); expect(richHistory).toStrictEqual([expectedHistoryItem]);
expect(total).toBe(1);
}); });
it('should load when queries are json-encoded strings', async () => { it('should load when queries are json-encoded strings', async () => {
@ -299,8 +300,9 @@ describe('RichHistoryLocalStorage', () => {
], ],
}; };
const result = await storage.getRichHistory(mockFilters); const { richHistory, total } = await storage.getRichHistory(mockFilters);
expect(result).toStrictEqual([expectedHistoryItem]); expect(richHistory).toStrictEqual([expectedHistoryItem]);
expect(total).toBe(1);
}); });
}); });
}); });

@ -37,10 +37,11 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
const allQueries = getRichHistoryDTOs().map(fromDTO); const allQueries = getRichHistoryDTOs().map(fromDTO);
const queries = filters.starred ? allQueries.filter((q) => q.starred === true) : allQueries; const queries = filters.starred ? allQueries.filter((q) => q.starred === true) : allQueries;
return filterAndSortQueries(queries, filters.sortOrder, filters.datasourceFilters, filters.search, [ const richHistory = filterAndSortQueries(queries, filters.sortOrder, filters.datasourceFilters, filters.search, [
filters.from, filters.from,
filters.to, filters.to,
]); ]);
return { richHistory, total: richHistory.length };
} }
async addToRichHistory(newRichHistoryQuery: Omit<RichHistoryQuery, 'id' | 'createdAt'>) { async addToRichHistory(newRichHistoryQuery: Omit<RichHistoryQuery, 'id' | 'createdAt'>) {

@ -78,6 +78,7 @@ describe('RichHistoryRemoteStorage', () => {
data: { data: {
result: { result: {
queryHistory: returnedDTOs, queryHistory: returnedDTOs,
totalCount: returnedDTOs.length,
}, },
}, },
}) })
@ -91,14 +92,22 @@ describe('RichHistoryRemoteStorage', () => {
const expectedLimit = 100; const expectedLimit = 100;
const expectedPage = 1; const expectedPage = 1;
const items = await storage.getRichHistory({ search, datasourceFilters, sortOrder, starred, to, from }); const { richHistory, total } = await storage.getRichHistory({
search,
datasourceFilters,
sortOrder,
starred,
to,
from,
});
expect(fetchMock).toBeCalledWith({ expect(fetchMock).toBeCalledWith({
method: 'GET', method: 'GET',
url: `/api/query-history?datasourceUid=ds1&datasourceUid=ds2&searchString=${search}&sort=time-desc&to=now-${from}d&from=now-${to}d&limit=${expectedLimit}&page=${expectedPage}&onlyStarred=${starred}`, url: `/api/query-history?datasourceUid=ds1&datasourceUid=ds2&searchString=${search}&sort=time-desc&to=now-${from}d&from=now-${to}d&limit=${expectedLimit}&page=${expectedPage}&onlyStarred=${starred}`,
requestId: 'query-history-get-all', requestId: 'query-history-get-all',
}); });
expect(items).toMatchObject([richHistoryQuery]); expect(richHistory).toMatchObject([richHistoryQuery]);
expect(total).toBe(1);
}); });
it('read starred home tab preferences', async () => { it('read starred home tab preferences', async () => {

@ -34,6 +34,7 @@ type RichHistoryRemoteStorageMigrationPayloadDTO = {
type RichHistoryRemoteStorageResultsPayloadDTO = { type RichHistoryRemoteStorageResultsPayloadDTO = {
result: { result: {
queryHistory: RichHistoryRemoteStorageDTO[]; queryHistory: RichHistoryRemoteStorageDTO[];
totalCount: number;
}; };
}; };
@ -64,8 +65,9 @@ export default class RichHistoryRemoteStorage implements RichHistoryStorage {
throw new Error('not supported yet'); throw new Error('not supported yet');
} }
async getRichHistory(filters: RichHistorySearchFilters): Promise<RichHistoryQuery[]> { async getRichHistory(filters: RichHistorySearchFilters) {
const params = buildQueryParams(filters); const params = buildQueryParams(filters);
const queryHistory = await lastValueFrom( const queryHistory = await lastValueFrom(
getBackendSrv().fetch({ getBackendSrv().fetch({
method: 'GET', method: 'GET',
@ -74,7 +76,12 @@ export default class RichHistoryRemoteStorage implements RichHistoryStorage {
requestId: 'query-history-get-all', requestId: 'query-history-get-all',
}) })
); );
return ((queryHistory.data as RichHistoryRemoteStorageResultsPayloadDTO).result.queryHistory || []).map(fromDTO);
const data = queryHistory.data as RichHistoryRemoteStorageResultsPayloadDTO;
const richHistory = (data.result.queryHistory || []).map(fromDTO);
const total = data.result.totalCount || 0;
return { richHistory, total };
} }
async getSettings(): Promise<RichHistorySettings> { async getSettings(): Promise<RichHistorySettings> {
@ -137,7 +144,7 @@ function buildQueryParams(filters: RichHistorySearchFilters): string {
params = params + `&to=${relativeFrom}`; params = params + `&to=${relativeFrom}`;
params = params + `&from=${relativeTo}`; params = params + `&from=${relativeTo}`;
params = params + `&limit=100`; params = params + `&limit=100`;
params = params + `&page=1`; params = params + `&page=${filters.page || 1}`;
if (filters.starred) { if (filters.starred) {
params = params + `&onlyStarred=${filters.starred}`; params = params + `&onlyStarred=${filters.starred}`;
} }

@ -28,12 +28,14 @@ export type RichHistoryStorageWarningDetails = {
message: string; message: string;
}; };
export type RichHistoryResults = { richHistory: RichHistoryQuery[]; total?: number };
/** /**
* @internal * @internal
* @alpha * @alpha
*/ */
export default interface RichHistoryStorage { export default interface RichHistoryStorage {
getRichHistory(filters: RichHistorySearchFilters): Promise<RichHistoryQuery[]>; getRichHistory(filters: RichHistorySearchFilters): Promise<RichHistoryResults>;
/** /**
* Creates new RichHistoryQuery, returns object with unique id and created date * Creates new RichHistoryQuery, returns object with unique id and created date

@ -184,11 +184,11 @@ describe('richHistory', () => {
}); });
it('migrates history', async () => { it('migrates history', async () => {
const history = [{ id: 'test' }, { id: 'test2' }]; const history = { richHistory: [{ id: 'test' }, { id: 'test2' }], total: 2 };
richHistoryLocalStorageMock.getRichHistory.mockReturnValue(history); richHistoryLocalStorageMock.getRichHistory.mockReturnValue(history);
await migrateQueryHistoryFromLocalStorage(); await migrateQueryHistoryFromLocalStorage();
expect(richHistoryRemoteStorageMock.migrate).toBeCalledWith(history); expect(richHistoryRemoteStorageMock.migrate).toBeCalledWith(history.richHistory);
}); });
it('does not migrate if there are no entries', async () => { it('does not migrate if there are no entries', async () => {
richHistoryLocalStorageMock.getRichHistory.mockReturnValue([]); richHistoryLocalStorageMock.getRichHistory.mockReturnValue([]);

@ -15,6 +15,7 @@ import { RichHistoryQuery } from 'app/types/explore';
import RichHistoryLocalStorage from '../history/RichHistoryLocalStorage'; import RichHistoryLocalStorage from '../history/RichHistoryLocalStorage';
import RichHistoryRemoteStorage from '../history/RichHistoryRemoteStorage'; import RichHistoryRemoteStorage from '../history/RichHistoryRemoteStorage';
import { import {
RichHistoryResults,
RichHistoryServiceError, RichHistoryServiceError,
RichHistoryStorageWarning, RichHistoryStorageWarning,
RichHistoryStorageWarningDetails, RichHistoryStorageWarningDetails,
@ -80,7 +81,7 @@ export async function addToRichHistory(
return {}; return {};
} }
export async function getRichHistory(filters: RichHistorySearchFilters): Promise<RichHistoryQuery[]> { export async function getRichHistory(filters: RichHistorySearchFilters): Promise<RichHistoryResults> {
return await getRichHistoryStorage().getRichHistory(filters); return await getRichHistoryStorage().getRichHistory(filters);
} }
@ -135,7 +136,7 @@ export async function migrateQueryHistoryFromLocalStorage(): Promise<LocalStorag
const richHistoryRemoteStorage = new RichHistoryRemoteStorage(); const richHistoryRemoteStorage = new RichHistoryRemoteStorage();
try { try {
const richHistory: RichHistoryQuery[] = await richHistoryLocalStorage.getRichHistory({ const { richHistory } = await richHistoryLocalStorage.getRichHistory({
datasourceFilters: [], datasourceFilters: [],
from: 0, from: 0,
search: '', search: '',

@ -26,4 +26,5 @@ export type RichHistorySearchFilters = {
from: number; from: number;
to: number; to: number;
starred: boolean; starred: boolean;
page?: number;
}; };

@ -28,9 +28,11 @@ const setup = (propOverrides?: Partial<RichHistoryProps>) => {
height: 100, height: 100,
activeDatasourceInstance: 'Test datasource', activeDatasourceInstance: 'Test datasource',
richHistory: [], richHistory: [],
richHistoryTotal: 0,
firstTab: Tabs.RichHistory, firstTab: Tabs.RichHistory,
deleteRichHistory: jest.fn(), deleteRichHistory: jest.fn(),
loadRichHistory: jest.fn(), loadRichHistory: jest.fn(),
loadMoreRichHistory: jest.fn(),
clearRichHistoryResults: jest.fn(), clearRichHistoryResults: jest.fn(),
onClose: jest.fn(), onClose: jest.fn(),
richHistorySearchFilters: { richHistorySearchFilters: {

@ -28,11 +28,13 @@ export const getSortOrderOptions = () =>
export interface RichHistoryProps extends Themeable { export interface RichHistoryProps extends Themeable {
richHistory: RichHistoryQuery[]; richHistory: RichHistoryQuery[];
richHistoryTotal?: number;
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; loadRichHistory: (exploreId: ExploreId) => void;
loadMoreRichHistory: (exploreId: ExploreId) => void;
clearRichHistoryResults: (exploreId: ExploreId) => void; clearRichHistoryResults: (exploreId: ExploreId) => void;
deleteRichHistory: () => void; deleteRichHistory: () => void;
activeDatasourceInstance: string; activeDatasourceInstance: string;
@ -59,6 +61,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
const filters = { const filters = {
...this.props.richHistorySearchFilters!, ...this.props.richHistorySearchFilters!,
...filtersToUpdate, ...filtersToUpdate,
page: 1, // always load fresh results when updating filters
}; };
this.props.updateHistorySearchFilters(this.props.exploreId, filters); this.props.updateHistorySearchFilters(this.props.exploreId, filters);
this.loadRichHistory(); this.loadRichHistory();
@ -96,8 +99,16 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
} }
render() { render() {
const { richHistory, height, exploreId, deleteRichHistory, onClose, firstTab, activeDatasourceInstance } = const {
this.props; richHistory,
richHistoryTotal,
height,
exploreId,
deleteRichHistory,
onClose,
firstTab,
activeDatasourceInstance,
} = this.props;
const { loading } = this.state; const { loading } = this.state;
const QueriesTab: TabConfig = { const QueriesTab: TabConfig = {
@ -106,9 +117,11 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
content: ( content: (
<RichHistoryQueriesTab <RichHistoryQueriesTab
queries={richHistory} queries={richHistory}
totalQueries={richHistoryTotal || 0}
loading={loading} loading={loading}
updateFilters={this.updateFilters} updateFilters={this.updateFilters}
clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)} clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)}
loadMoreRichHistory={() => this.props.loadMoreRichHistory(this.props.exploreId)}
activeDatasourceInstance={activeDatasourceInstance} activeDatasourceInstance={activeDatasourceInstance}
richHistorySettings={this.props.richHistorySettings} richHistorySettings={this.props.richHistorySettings}
richHistorySearchFilters={this.props.richHistorySearchFilters} richHistorySearchFilters={this.props.richHistorySearchFilters}
@ -125,10 +138,12 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
content: ( content: (
<RichHistoryStarredTab <RichHistoryStarredTab
queries={richHistory} queries={richHistory}
totalQueries={richHistoryTotal || 0}
loading={loading} loading={loading}
activeDatasourceInstance={activeDatasourceInstance} activeDatasourceInstance={activeDatasourceInstance}
updateFilters={this.updateFilters} updateFilters={this.updateFilters}
clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)} clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)}
loadMoreRichHistory={() => this.props.loadMoreRichHistory(this.props.exploreId)}
richHistorySettings={this.props.richHistorySettings} richHistorySettings={this.props.richHistorySettings}
richHistorySearchFilters={this.props.richHistorySearchFilters} richHistorySearchFilters={this.props.richHistorySearchFilters}
exploreId={exploreId} exploreId={exploreId}

@ -29,6 +29,7 @@ const setup = (propOverrides?: Partial<Props>) => {
deleteRichHistory: jest.fn(), deleteRichHistory: jest.fn(),
initRichHistory: jest.fn(), initRichHistory: jest.fn(),
loadRichHistory: jest.fn(), loadRichHistory: jest.fn(),
loadMoreRichHistory: jest.fn(),
clearRichHistoryResults: jest.fn(), clearRichHistoryResults: jest.fn(),
updateHistorySearchFilters: jest.fn(), updateHistorySearchFilters: jest.fn(),
updateHistorySettings: jest.fn(), updateHistorySettings: jest.fn(),
@ -47,6 +48,7 @@ const setup = (propOverrides?: Partial<Props>) => {
activeDatasourceOnly: true, activeDatasourceOnly: true,
lastUsedDatasourceFilters: [], lastUsedDatasourceFilters: [],
}, },
richHistoryTotal: 0,
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);

@ -13,6 +13,7 @@ import {
deleteRichHistory, deleteRichHistory,
initRichHistory, initRichHistory,
loadRichHistory, loadRichHistory,
loadMoreRichHistory,
clearRichHistoryResults, clearRichHistoryResults,
updateHistorySettings, updateHistorySettings,
updateHistorySearchFilters, updateHistorySearchFilters,
@ -30,9 +31,10 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
const richHistorySettings = explore.richHistorySettings; const richHistorySettings = explore.richHistorySettings;
const { datasourceInstance } = item; const { datasourceInstance } = item;
const firstTab = richHistorySettings?.starredTabAsFirstTab ? Tabs.Starred : Tabs.RichHistory; const firstTab = richHistorySettings?.starredTabAsFirstTab ? Tabs.Starred : Tabs.RichHistory;
const { richHistory } = item; const { richHistory, richHistoryTotal } = item;
return { return {
richHistory, richHistory,
richHistoryTotal,
firstTab, firstTab,
activeDatasourceInstance: datasourceInstance!.name, activeDatasourceInstance: datasourceInstance!.name,
richHistorySettings, richHistorySettings,
@ -43,6 +45,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
const mapDispatchToProps = { const mapDispatchToProps = {
initRichHistory, initRichHistory,
loadRichHistory, loadRichHistory,
loadMoreRichHistory,
clearRichHistoryResults, clearRichHistoryResults,
updateHistorySettings, updateHistorySettings,
updateHistorySearchFilters, updateHistorySearchFilters,
@ -64,6 +67,7 @@ export function RichHistoryContainer(props: Props) {
const { const {
richHistory, richHistory,
richHistoryTotal,
width, width,
firstTab, firstTab,
activeDatasourceInstance, activeDatasourceInstance,
@ -71,6 +75,7 @@ export function RichHistoryContainer(props: Props) {
deleteRichHistory, deleteRichHistory,
initRichHistory, initRichHistory,
loadRichHistory, loadRichHistory,
loadMoreRichHistory,
clearRichHistoryResults, clearRichHistoryResults,
richHistorySettings, richHistorySettings,
updateHistorySettings, updateHistorySettings,
@ -96,6 +101,7 @@ export function RichHistoryContainer(props: Props) {
> >
<RichHistory <RichHistory
richHistory={richHistory} richHistory={richHistory}
richHistoryTotal={richHistoryTotal}
firstTab={firstTab} firstTab={firstTab}
activeDatasourceInstance={activeDatasourceInstance} activeDatasourceInstance={activeDatasourceInstance}
exploreId={exploreId} exploreId={exploreId}
@ -107,6 +113,7 @@ export function RichHistoryContainer(props: Props) {
updateHistorySettings={updateHistorySettings} updateHistorySettings={updateHistorySettings}
updateHistorySearchFilters={updateHistorySearchFilters} updateHistorySearchFilters={updateHistorySearchFilters}
loadRichHistory={loadRichHistory} loadRichHistory={loadRichHistory}
loadMoreRichHistory={loadMoreRichHistory}
clearRichHistoryResults={clearRichHistoryResults} clearRichHistoryResults={clearRichHistoryResults}
/> />
</ExploreDrawer> </ExploreDrawer>

@ -3,7 +3,7 @@ import React, { useEffect } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { FilterInput, MultiSelect, RangeSlider, Select, stylesFactory, useTheme } from '@grafana/ui'; import { Button, FilterInput, MultiSelect, RangeSlider, Select, stylesFactory, useTheme } from '@grafana/ui';
import { import {
createDatasourcesList, createDatasourcesList,
mapNumbertoTimeInSlider, mapNumbertoTimeInSlider,
@ -19,10 +19,12 @@ import RichHistoryCard from './RichHistoryCard';
export interface Props { export interface Props {
queries: RichHistoryQuery[]; queries: RichHistoryQuery[];
totalQueries: number;
loading: boolean; loading: boolean;
activeDatasourceInstance: string; activeDatasourceInstance: string;
updateFilters: (filtersToUpdate?: Partial<RichHistorySearchFilters>) => void; updateFilters: (filtersToUpdate?: Partial<RichHistorySearchFilters>) => void;
clearRichHistoryResults: () => void; clearRichHistoryResults: () => void;
loadMoreRichHistory: () => void;
richHistorySettings: RichHistorySettings; richHistorySettings: RichHistorySettings;
richHistorySearchFilters?: RichHistorySearchFilters; richHistorySearchFilters?: RichHistorySearchFilters;
exploreId: ExploreId; exploreId: ExploreId;
@ -121,10 +123,12 @@ const getStyles = stylesFactory((theme: GrafanaTheme, height: number) => {
export function RichHistoryQueriesTab(props: Props) { export function RichHistoryQueriesTab(props: Props) {
const { const {
queries, queries,
totalQueries,
loading, loading,
richHistorySearchFilters, richHistorySearchFilters,
updateFilters, updateFilters,
clearRichHistoryResults, clearRichHistoryResults,
loadMoreRichHistory,
richHistorySettings, richHistorySettings,
exploreId, exploreId,
height, height,
@ -166,6 +170,7 @@ export function RichHistoryQueriesTab(props: Props) {
*/ */
const mappedQueriesToHeadings = mapQueriesToHeadings(queries, richHistorySearchFilters.sortOrder); const mappedQueriesToHeadings = mapQueriesToHeadings(queries, richHistorySearchFilters.sortOrder);
const sortOrderOptions = getSortOrderOptions(); const sortOrderOptions = getSortOrderOptions();
const partialResults = queries.length && queries.length !== totalQueries;
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -231,7 +236,11 @@ export function RichHistoryQueriesTab(props: Props) {
return ( return (
<div key={heading}> <div key={heading}>
<div className={styles.heading}> <div className={styles.heading}>
{heading} <span className={styles.queries}>{mappedQueriesToHeadings[heading].length} queries</span> {heading}{' '}
<span className={styles.queries}>
{partialResults ? 'Displaying ' : ''}
{mappedQueriesToHeadings[heading].length} queries
</span>
</div> </div>
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => { {mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => {
const idx = listOfDatasources.findIndex((d) => d.name === q.datasourceName); const idx = listOfDatasources.findIndex((d) => d.name === q.datasourceName);
@ -248,6 +257,11 @@ export function RichHistoryQueriesTab(props: Props) {
</div> </div>
); );
})} })}
{partialResults ? (
<div>
Showing {queries.length} of {totalQueries} <Button onClick={loadMoreRichHistory}>Load more</Button>
</div>
) : null}
<div className={styles.footer}> <div className={styles.footer}>
{!config.queryHistoryEnabled ? 'The history is local to your browser and is not shared with others.' : ''} {!config.queryHistoryEnabled ? 'The history is local to your browser and is not shared with others.' : ''}
</div> </div>

@ -22,8 +22,10 @@ const setup = (activeDatasourceOnly = false) => {
const props: Props = { const props: Props = {
queries: [], queries: [],
loading: false, loading: false,
totalQueries: 0,
activeDatasourceInstance: {} as any, activeDatasourceInstance: {} as any,
updateFilters: jest.fn(), updateFilters: jest.fn(),
loadMoreRichHistory: jest.fn(),
clearRichHistoryResults: jest.fn(), clearRichHistoryResults: jest.fn(),
exploreId: ExploreId.left, exploreId: ExploreId.left,
richHistorySettings: { richHistorySettings: {

@ -3,7 +3,7 @@ import React, { useEffect } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { stylesFactory, useTheme, Select, MultiSelect, FilterInput } from '@grafana/ui'; import { stylesFactory, useTheme, Select, MultiSelect, FilterInput, Button } from '@grafana/ui';
import { import {
createDatasourcesList, createDatasourcesList,
SortOrder, SortOrder,
@ -17,10 +17,12 @@ import RichHistoryCard from './RichHistoryCard';
export interface Props { export interface Props {
queries: RichHistoryQuery[]; queries: RichHistoryQuery[];
totalQueries: number;
loading: boolean; loading: boolean;
activeDatasourceInstance: string; activeDatasourceInstance: string;
updateFilters: (filtersToUpdate: Partial<RichHistorySearchFilters>) => void; updateFilters: (filtersToUpdate: Partial<RichHistorySearchFilters>) => void;
clearRichHistoryResults: () => void; clearRichHistoryResults: () => void;
loadMoreRichHistory: () => void;
richHistorySearchFilters?: RichHistorySearchFilters; richHistorySearchFilters?: RichHistorySearchFilters;
richHistorySettings: RichHistorySettings; richHistorySettings: RichHistorySettings;
exploreId: ExploreId; exploreId: ExploreId;
@ -74,9 +76,11 @@ export function RichHistoryStarredTab(props: Props) {
const { const {
updateFilters, updateFilters,
clearRichHistoryResults, clearRichHistoryResults,
loadMoreRichHistory,
activeDatasourceInstance, activeDatasourceInstance,
richHistorySettings, richHistorySettings,
queries, queries,
totalQueries,
loading, loading,
richHistorySearchFilters, richHistorySearchFilters,
exploreId, exploreId,
@ -161,6 +165,11 @@ export function RichHistoryStarredTab(props: Props) {
/> />
); );
})} })}
{queries.length && queries.length !== totalQueries ? (
<div>
Showing {queries.length} of {totalQueries} <Button onClick={loadMoreRichHistory}>Load more</Button>
</div>
) : null}
<div className={styles.footer}> <div className={styles.footer}>
{!config.queryHistoryEnabled ? 'The history is local to your browser and is not shared with others.' : ''} {!config.queryHistoryEnabled ? 'The history is local to your browser and is not shared with others.' : ''}
</div> </div>

@ -15,7 +15,7 @@ export const assertQueryHistoryExists = async (query: string, exploreId: Explore
export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId: ExploreId = ExploreId.left) => { export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId: ExploreId = ExploreId.left) => {
const selector = withinExplore(exploreId); const selector = withinExplore(exploreId);
await waitFor(() => { await waitFor(() => {
expect(selector.getByText(`${expectedQueryTexts.length} queries`)).toBeInTheDocument(); expect(selector.getByText(new RegExp(`${expectedQueryTexts.length} queries`))).toBeInTheDocument();
const queryTexts = selector.getAllByLabelText('Query text'); const queryTexts = selector.getAllByLabelText('Query text');
expectedQueryTexts.forEach((expectedQueryText, queryIndex) => { expectedQueryTexts.forEach((expectedQueryText, queryIndex) => {
expect(queryTexts[queryIndex]).toHaveTextContent(expectedQueryText); expect(queryTexts[queryIndex]).toHaveTextContent(expectedQueryText);
@ -48,3 +48,15 @@ export const assertDataSourceFilterVisibility = (visible: boolean, exploreId: Ex
expect(filterInput).not.toBeInTheDocument(); expect(filterInput).not.toBeInTheDocument();
} }
}; };
export const assertQueryHistoryElementsShown = (
shown: number,
total: number,
exploreId: ExploreId = ExploreId.left
) => {
expect(withinExplore(exploreId).queryByText(`Showing ${shown} of ${total}`)).toBeInTheDocument();
};
export const assertLoadMoreQueryHistoryNotVisible = (exploreId: ExploreId = ExploreId.left) => {
expect(withinExplore(exploreId).queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
};

@ -66,6 +66,11 @@ export const deleteQueryHistory = (queryIndex: number, exploreId: ExploreId = Ex
invokeAction(queryIndex, 'Delete query', exploreId); invokeAction(queryIndex, 'Delete query', exploreId);
}; };
export const loadMoreQueryHistory = async (exploreId: ExploreId = ExploreId.left) => {
const button = withinExplore(exploreId).getByRole('button', { name: 'Load more' });
await userEvent.click(button);
};
const invokeAction = async (queryIndex: number, actionAccessibleName: string, exploreId: ExploreId) => { const invokeAction = async (queryIndex: number, actionAccessibleName: string, exploreId: ExploreId) => {
const selector = withinExplore(exploreId); const selector = withinExplore(exploreId);
const buttons = selector.getAllByRole('button', { name: actionAccessibleName }); const buttons = selector.getAllByRole('button', { name: actionAccessibleName });

@ -12,6 +12,7 @@ import { Echo } from 'app/core/services/echo/Echo';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { RICH_HISTORY_KEY, RichHistoryLocalStorageDTO } from '../../../../core/history/RichHistoryLocalStorage'; import { RICH_HISTORY_KEY, RichHistoryLocalStorageDTO } from '../../../../core/history/RichHistoryLocalStorage';
import { RICH_HISTORY_SETTING_KEYS } from '../../../../core/history/richHistoryLocalStorageUtils';
import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource'; import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource';
import { LokiQuery } from '../../../../plugins/datasource/loki/types'; import { LokiQuery } from '../../../../plugins/datasource/loki/types';
import { ExploreId } from '../../../../types'; import { ExploreId } from '../../../../types';
@ -158,6 +159,10 @@ export const withinExplore = (exploreId: ExploreId) => {
return within(container[exploreId === ExploreId.left ? 0 : 1]); return within(container[exploreId === ExploreId.left ? 0 : 1]);
}; };
export const localStorageHasAlreadyBeenMigrated = () => {
window.localStorage.setItem(RICH_HISTORY_SETTING_KEYS.migrated, 'true');
};
export const setupLocalStorageRichHistory = (dsName: string) => { export const setupLocalStorageRichHistory = (dsName: string) => {
window.localStorage.setItem( window.localStorage.setItem(
RICH_HISTORY_KEY, RICH_HISTORY_KEY,

@ -9,7 +9,9 @@ import { ExploreId } from '../../../types';
import { import {
assertDataSourceFilterVisibility, assertDataSourceFilterVisibility,
assertLoadMoreQueryHistoryNotVisible,
assertQueryHistory, assertQueryHistory,
assertQueryHistoryElementsShown,
assertQueryHistoryExists, assertQueryHistoryExists,
assertQueryHistoryIsStarred, assertQueryHistoryIsStarred,
assertQueryHistoryTabIsSelected, assertQueryHistoryTabIsSelected,
@ -18,6 +20,7 @@ import {
closeQueryHistory, closeQueryHistory,
deleteQueryHistory, deleteQueryHistory,
inputQuery, inputQuery,
loadMoreQueryHistory,
openQueryHistory, openQueryHistory,
runQuery, runQuery,
selectOnlyActiveDataSource, selectOnlyActiveDataSource,
@ -26,7 +29,13 @@ import {
switchToQueryHistoryTab, switchToQueryHistoryTab,
} from './helper/interactions'; } from './helper/interactions';
import { makeLogsQueryResponse } from './helper/query'; import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, setupLocalStorageRichHistory, tearDown, waitForExplore } from './helper/setup'; import {
localStorageHasAlreadyBeenMigrated,
setupExplore,
setupLocalStorageRichHistory,
tearDown,
waitForExplore,
} from './helper/setup';
const fetchMock = jest.fn(); const fetchMock = jest.fn();
const postMock = jest.fn(); const postMock = jest.fn();
@ -215,4 +224,26 @@ describe('Explore: Query History', () => {
); );
}); });
}); });
it('pagination', async () => {
config.queryHistoryEnabled = true;
localStorageHasAlreadyBeenMigrated();
const { datasources } = setupExplore();
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
fetchMock.mockReturnValue(
of({
data: { result: { queryHistory: [{ datasourceUid: 'loki', queries: [{ expr: 'query' }] }], totalCount: 2 } },
})
);
await waitForExplore();
await openQueryHistory();
await assertQueryHistory(['{"expr":"query"}']);
assertQueryHistoryElementsShown(1, 2);
await loadMoreQueryHistory();
await assertQueryHistory(['{"expr":"query"}', '{"expr":"query"}']);
assertLoadMoreQueryHistoryNotVisible();
});
}); });

@ -257,9 +257,11 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
state = historyReducer(state, action); state = historyReducer(state, action);
if (richHistoryUpdatedAction.match(action)) { if (richHistoryUpdatedAction.match(action)) {
const { richHistory, total } = action.payload.richHistoryResults;
return { return {
...state, ...state,
richHistory: action.payload.richHistory, richHistory,
richHistoryTotal: total,
}; };
} }

@ -60,7 +60,12 @@ const updateRichHistoryState = ({ updatedQuery, deletedId }: SyncHistoryUpdatesO
.map((query) => (query.id === updatedQuery?.id ? updatedQuery : query)) .map((query) => (query.id === updatedQuery?.id ? updatedQuery : query))
// or remove // or remove
.filter((query) => query.id !== deletedId); .filter((query) => query.id !== deletedId);
dispatch(richHistoryUpdatedAction({ richHistory: newRichHistory, exploreId })); dispatch(
richHistoryUpdatedAction({
richHistoryResults: { richHistory: newRichHistory, total: item.richHistoryTotal },
exploreId,
})
);
}); });
}; };
}; };
@ -118,8 +123,12 @@ export const deleteHistoryItem = (id: string): ThunkResult<void> => {
export const deleteRichHistory = (): ThunkResult<void> => { export const deleteRichHistory = (): ThunkResult<void> => {
return async (dispatch) => { return async (dispatch) => {
await deleteAllFromRichHistory(); await deleteAllFromRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory: [], exploreId: ExploreId.left })); dispatch(
dispatch(richHistoryUpdatedAction({ richHistory: [], exploreId: ExploreId.right })); richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId: ExploreId.left })
);
dispatch(
richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId: ExploreId.right })
);
}; };
}; };
@ -127,8 +136,24 @@ export const loadRichHistory = (exploreId: ExploreId): ThunkResult<void> => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const filters = getState().explore![exploreId]?.richHistorySearchFilters; const filters = getState().explore![exploreId]?.richHistorySearchFilters;
if (filters) { if (filters) {
const richHistory = await getRichHistory(filters); const richHistoryResults = await getRichHistory(filters);
dispatch(richHistoryUpdatedAction({ richHistory, exploreId })); dispatch(richHistoryUpdatedAction({ richHistoryResults, exploreId }));
}
};
};
export const loadMoreRichHistory = (exploreId: ExploreId): ThunkResult<void> => {
return async (dispatch, getState) => {
const currentFilters = getState().explore![exploreId]?.richHistorySearchFilters;
const currentRichHistory = getState().explore![exploreId]?.richHistory;
if (currentFilters && currentRichHistory) {
const nextFilters = { ...currentFilters, page: (currentFilters?.page || 1) + 1 };
const moreRichHistory = await getRichHistory(nextFilters);
const richHistory = [...currentRichHistory, ...moreRichHistory.richHistory];
dispatch(richHistorySearchFiltersUpdatedAction({ filters: nextFilters, exploreId }));
dispatch(
richHistoryUpdatedAction({ richHistoryResults: { richHistory, total: moreRichHistory.total }, exploreId })
);
} }
}; };
}; };
@ -136,7 +161,7 @@ export const loadRichHistory = (exploreId: ExploreId): ThunkResult<void> => {
export const clearRichHistoryResults = (exploreId: ExploreId): ThunkResult<void> => { export const clearRichHistoryResults = (exploreId: ExploreId): ThunkResult<void> => {
return async (dispatch) => { return async (dispatch) => {
dispatch(richHistorySearchFiltersUpdatedAction({ filters: undefined, exploreId })); dispatch(richHistorySearchFiltersUpdatedAction({ filters: undefined, exploreId }));
dispatch(richHistoryUpdatedAction({ richHistory: [], exploreId })); dispatch(richHistoryUpdatedAction({ richHistoryResults: { richHistory: [], total: 0 }, exploreId }));
}; };
}; };

@ -5,8 +5,9 @@ import { ExploreUrlState, serializeStateToUrlParam, SplitOpen, UrlQueryMap } fro
import { DataSourceSrv, getDataSourceSrv, locationService } from '@grafana/runtime'; import { DataSourceSrv, getDataSourceSrv, locationService } from '@grafana/runtime';
import { GetExploreUrlArguments, stopQueryState } from 'app/core/utils/explore'; import { GetExploreUrlArguments, stopQueryState } from 'app/core/utils/explore';
import { PanelModel } from 'app/features/dashboard/state'; import { PanelModel } from 'app/features/dashboard/state';
import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery } from 'app/types/explore'; import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore';
import { RichHistoryResults } from '../../../core/history/RichHistoryStorage';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes'; import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
import { ThunkResult } from '../../../types'; import { ThunkResult } from '../../../types';
import { TimeSrv } from '../../dashboard/services/TimeSrv'; import { TimeSrv } from '../../dashboard/services/TimeSrv';
@ -23,7 +24,7 @@ export interface SyncTimesPayload {
} }
export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes'); export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes');
export const richHistoryUpdatedAction = createAction<{ richHistory: RichHistoryQuery[]; exploreId: ExploreId }>( export const richHistoryUpdatedAction = createAction<{ richHistoryResults: RichHistoryResults; exploreId: ExploreId }>(
'explore/richHistoryUpdated' 'explore/richHistoryUpdated'
); );
export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction'); export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction');

@ -166,6 +166,7 @@ export interface ExploreItemState {
*/ */
richHistory: RichHistoryQuery[]; richHistory: RichHistoryQuery[];
richHistorySearchFilters?: RichHistorySearchFilters; richHistorySearchFilters?: RichHistorySearchFilters;
richHistoryTotal?: number;
/** /**
* 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.

Loading…
Cancel
Save