Skip to content

Commit f5b45cf

Browse files
committed
[Lens] Add "no data" popover (elastic#69147)
1 parent 0ce23d5 commit f5b45cf

File tree

33 files changed

+359
-21
lines changed

33 files changed

+359
-21
lines changed

docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<b>Signature:</b>
88

99
```typescript
10-
SearchBar: React.ComponentClass<Pick<Pick<SearchBarProps, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "intl" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & {
11-
WrappedComponent: React.ComponentType<Pick<SearchBarProps, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "intl" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated"> & ReactIntl.InjectedIntlProps>;
10+
SearchBar: React.ComponentClass<Pick<Pick<SearchBarProps, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "intl" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated">, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated">, any> & {
11+
WrappedComponent: React.ComponentType<Pick<SearchBarProps, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "intl" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated"> & ReactIntl.InjectedIntlProps>;
1212
}
1313
```

src/plugins/data/public/public.api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,8 +1758,8 @@ export const search: {
17581758
// Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
17591759
//
17601760
// @public (undocumented)
1761-
export const SearchBar: React.ComponentClass<Pick<Pick<SearchBarProps, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "intl" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & {
1762-
WrappedComponent: React.ComponentType<Pick<SearchBarProps, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "intl" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated"> & ReactIntl.InjectedIntlProps>;
1761+
export const SearchBar: React.ComponentClass<Pick<Pick<SearchBarProps, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "intl" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated">, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated">, any> & {
1762+
WrappedComponent: React.ComponentType<Pick<SearchBarProps, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "intl" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated"> & ReactIntl.InjectedIntlProps>;
17631763
};
17641764

17651765
// Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import React from 'react';
21+
import { mountWithIntl as mount } from 'test_utils/enzyme_helpers';
22+
import { NoDataPopover } from './no_data_popover';
23+
import { EuiTourStep } from '@elastic/eui';
24+
import { act } from 'react-dom/test-utils';
25+
26+
describe('NoDataPopover', () => {
27+
const createMockStorage = () => ({
28+
get: jest.fn(),
29+
set: jest.fn(),
30+
remove: jest.fn(),
31+
clear: jest.fn(),
32+
});
33+
34+
it('should hide popover if showNoDataPopover is set to false', () => {
35+
const Child = () => <span />;
36+
const instance = mount(
37+
<NoDataPopover storage={createMockStorage()} showNoDataPopover={false}>
38+
<Child />
39+
</NoDataPopover>
40+
);
41+
expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false);
42+
expect(instance.find(EuiTourStep).find(Child)).toHaveLength(1);
43+
});
44+
45+
it('should hide popover if showNoDataPopover is set to true, but local storage flag is set', () => {
46+
const child = <span />;
47+
const storage = createMockStorage();
48+
storage.get.mockReturnValue(true);
49+
const instance = mount(
50+
<NoDataPopover storage={storage} showNoDataPopover={false}>
51+
{child}
52+
</NoDataPopover>
53+
);
54+
expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false);
55+
});
56+
57+
it('should render popover if showNoDataPopover is set to true and local storage flag is not set', () => {
58+
const child = <span />;
59+
const instance = mount(
60+
<NoDataPopover storage={createMockStorage()} showNoDataPopover={true}>
61+
{child}
62+
</NoDataPopover>
63+
);
64+
expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(true);
65+
});
66+
67+
it('should hide popover if it is closed', async () => {
68+
const props = {
69+
children: <span />,
70+
showNoDataPopover: true,
71+
storage: createMockStorage(),
72+
};
73+
const instance = mount(<NoDataPopover {...props} />);
74+
act(() => {
75+
instance.find(EuiTourStep).prop('closePopover')!();
76+
});
77+
instance.setProps({ ...props });
78+
expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false);
79+
});
80+
81+
it('should set local storage flag and hide on closing with button', () => {
82+
const props = {
83+
children: <span />,
84+
showNoDataPopover: true,
85+
storage: createMockStorage(),
86+
};
87+
const instance = mount(<NoDataPopover {...props} />);
88+
act(() => {
89+
instance.find(EuiTourStep).prop('footerAction')!.props.onClick();
90+
});
91+
instance.setProps({ ...props });
92+
expect(props.storage.set).toHaveBeenCalledWith(expect.any(String), true);
93+
expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false);
94+
});
95+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { ReactElement, useEffect, useState } from 'react';
21+
import React from 'react';
22+
import { EuiButtonEmpty, EuiText, EuiTourStep } from '@elastic/eui';
23+
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
24+
import { i18n } from '@kbn/i18n';
25+
26+
const NO_DATA_POPOVER_STORAGE_KEY = 'data.noDataPopover';
27+
28+
export function NoDataPopover({
29+
showNoDataPopover,
30+
storage,
31+
children,
32+
}: {
33+
showNoDataPopover?: boolean;
34+
storage: IStorageWrapper;
35+
children: ReactElement;
36+
}) {
37+
const [noDataPopoverDismissed, setNoDataPopoverDismissed] = useState(() =>
38+
Boolean(storage.get(NO_DATA_POPOVER_STORAGE_KEY))
39+
);
40+
const [noDataPopoverVisible, setNoDataPopoverVisible] = useState(false);
41+
42+
useEffect(() => {
43+
if (showNoDataPopover && !noDataPopoverDismissed) {
44+
setNoDataPopoverVisible(true);
45+
}
46+
}, [noDataPopoverDismissed, showNoDataPopover]);
47+
48+
return (
49+
<EuiTourStep
50+
onFinish={() => {}}
51+
closePopover={() => {
52+
setNoDataPopoverVisible(false);
53+
}}
54+
content={
55+
<EuiText size="s">
56+
<p style={{ maxWidth: 300 }}>
57+
{i18n.translate('data.noDataPopover.content', {
58+
defaultMessage:
59+
"This time range doesn't contain any data. Increase or adjust the time range to see more fields and create charts",
60+
})}
61+
</p>
62+
</EuiText>
63+
}
64+
minWidth={300}
65+
anchorPosition="downCenter"
66+
step={1}
67+
stepsTotal={1}
68+
isStepOpen={noDataPopoverVisible}
69+
subtitle={i18n.translate('data.noDataPopover.title', { defaultMessage: 'Tip' })}
70+
title=""
71+
footerAction={
72+
<EuiButtonEmpty
73+
size="s"
74+
data-test-subj="noDataPopoverDismissButton"
75+
onClick={() => {
76+
storage.set(NO_DATA_POPOVER_STORAGE_KEY, true);
77+
setNoDataPopoverDismissed(true);
78+
setNoDataPopoverVisible(false);
79+
}}
80+
>
81+
{i18n.translate('data.noDataPopover.dismissAction', {
82+
defaultMessage: "Don't show again",
83+
})}
84+
</EuiButtonEmpty>
85+
}
86+
>
87+
<div
88+
onFocus={() => {
89+
setNoDataPopoverVisible(false);
90+
}}
91+
>
92+
{children}
93+
</div>
94+
</EuiTourStep>
95+
);
96+
}

src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useKibana, toMountPoint } from '../../../../kibana_react/public';
4040
import { QueryStringInput } from './query_string_input';
4141
import { doesKueryExpressionHaveLuceneSyntaxError, UI_SETTINGS } from '../../../common';
4242
import { PersistedLog, getQueryLog } from '../../query';
43+
import { NoDataPopover } from './no_data_popover';
4344

4445
interface Props {
4546
query?: Query;
@@ -63,6 +64,7 @@ interface Props {
6364
customSubmitButton?: any;
6465
isDirty: boolean;
6566
timeHistory?: TimeHistoryContract;
67+
indicateNoData?: boolean;
6668
}
6769

6870
export function QueryBarTopRow(props: Props) {
@@ -230,10 +232,12 @@ export function QueryBarTopRow(props: Props) {
230232
}
231233

232234
return (
233-
<EuiFlexGroup responsive={false} gutterSize="s">
234-
{renderDatePicker()}
235-
<EuiFlexItem grow={false}>{button}</EuiFlexItem>
236-
</EuiFlexGroup>
235+
<NoDataPopover storage={storage} showNoDataPopover={props.indicateNoData}>
236+
<EuiFlexGroup responsive={false} gutterSize="s">
237+
{renderDatePicker()}
238+
<EuiFlexItem grow={false}>{button}</EuiFlexItem>
239+
</EuiFlexGroup>
240+
</NoDataPopover>
237241
);
238242
}
239243

src/plugins/data/public/ui/search_bar/create_search_bar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps)
198198
showSaveQuery={props.showSaveQuery}
199199
screenTitle={props.screenTitle}
200200
indexPatterns={props.indexPatterns}
201+
indicateNoData={props.indicateNoData}
201202
timeHistory={data.query.timefilter.history}
202203
dateRangeFrom={timeRange.from}
203204
dateRangeTo={timeRange.to}

src/plugins/data/public/ui/search_bar/search_bar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export interface SearchBarOwnProps {
7575
onClearSavedQuery?: () => void;
7676

7777
onRefresh?: (payload: { dateRange: TimeRange }) => void;
78+
indicateNoData?: boolean;
7879
}
7980

8081
export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps;
@@ -402,6 +403,7 @@ class SearchBarUI extends Component<SearchBarProps, State> {
402403
this.props.customSubmitButton ? this.props.customSubmitButton : undefined
403404
}
404405
dataTestSubj={this.props.dataTestSubj}
406+
indicateNoData={this.props.indicateNoData}
405407
/>
406408
);
407409
}

test/functional/page_objects/time_picker.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo
5252
await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime);
5353
}
5454

55+
async ensureHiddenNoDataPopover() {
56+
const isVisible = await testSubjects.exists('noDataPopoverDismissButton');
57+
if (isVisible) {
58+
await testSubjects.click('noDataPopoverDismissButton');
59+
}
60+
}
61+
5562
/**
5663
* the provides a quicker way to set the timepicker to the default range, saves a few seconds
5764
*/

x-pack/plugins/lens/public/app_plugin/app.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ describe('Lens App', () => {
226226
"query": "",
227227
},
228228
"savedQuery": undefined,
229+
"showNoDataPopover": [Function],
229230
},
230231
],
231232
]

x-pack/plugins/lens/public/app_plugin/app.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from '../../../../../src/plugins/data/public';
4141

4242
interface State {
43+
indicateNoData: boolean;
4344
isLoading: boolean;
4445
isSaveModalVisible: boolean;
4546
indexPatternsForTopNav: IndexPatternInstance[];
@@ -97,9 +98,27 @@ export function App({
9798
toDate: currentRange.to,
9899
},
99100
filters: [],
101+
indicateNoData: false,
100102
};
101103
});
102104

105+
const showNoDataPopover = useCallback(() => {
106+
setState((prevState) => ({ ...prevState, indicateNoData: true }));
107+
}, [setState]);
108+
109+
useEffect(() => {
110+
if (state.indicateNoData) {
111+
setState((prevState) => ({ ...prevState, indicateNoData: false }));
112+
}
113+
}, [
114+
setState,
115+
state.indicateNoData,
116+
state.query,
117+
state.filters,
118+
state.dateRange,
119+
state.indexPatternsForTopNav,
120+
]);
121+
103122
const { lastKnownDoc } = state;
104123

105124
const isSaveable =
@@ -458,6 +477,7 @@ export function App({
458477
query={state.query}
459478
dateRangeFrom={state.dateRange.fromDate}
460479
dateRangeTo={state.dateRange.toDate}
480+
indicateNoData={state.indicateNoData}
461481
/>
462482
</div>
463483

@@ -472,6 +492,7 @@ export function App({
472492
savedQuery: state.savedQuery,
473493
doc: state.persistedDoc,
474494
onError,
495+
showNoDataPopover,
475496
onChange: ({ filterableIndexPatterns, doc }) => {
476497
if (!_.isEqual(state.persistedDoc, doc)) {
477498
setState((s) => ({ ...s, lastKnownDoc: doc }));

0 commit comments

Comments
 (0)