[Discover] Add a way to quickly expand time range from "No results" screen (#147195)

Related to issue https://github.com/elastic/kibana/issues/12608
A part of Spacetime project
https://github.com/elastic/kibana/pull/146729 but only for "No results"
UI, excluding the time picker changes.

## Summary

This PR extends the "No results matches your search criteria. Expand
your time range..." message to allow users quickly expand the time range
by clicking on a link.

<img width="1492" alt="Screenshot 2022-12-07 at 14 38 45"
src="https://user-images.githubusercontent.com/1415710/206221177-1a466b98-6cd3-494d-b7fe-09fdd43b1222.png">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia Rechkunova 2023-02-01 12:24:45 +01:00 committed by GitHub
parent abfe96ff89
commit 6f3b29df5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 738 additions and 208 deletions

View file

@ -19,7 +19,6 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
import { isOfQueryType } from '@kbn/es-query';
import classNames from 'classnames';
import { generateFilters } from '@kbn/data-plugin/public';
import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/public';
@ -42,7 +41,6 @@ import { DataMainMsg, RecordRawType } from '../../services/discover_data_state_c
import { useColumns } from '../../../../hooks/use_data_grid_columns';
import { FetchStatus } from '../../../types';
import { useDataState } from '../../hooks/use_data_state';
import { hasActiveFilter } from './utils';
import { getRawRecordType } from '../../utils/get_raw_record_type';
import { SavedSearchURLConflictCallout } from '../../../../components/saved_search_url_conflict_callout/saved_search_url_conflict_callout';
import { DiscoverHistogramLayout } from './discover_histogram_layout';
@ -84,10 +82,9 @@ export function DiscoverLayout({
inspector,
} = useDiscoverServices();
const { main$ } = stateContainer.dataState.data$;
const [query, savedQuery, filters, columns, sort] = useAppStateSelector((state) => [
const [query, savedQuery, columns, sort] = useAppStateSelector((state) => [
state.query,
state.savedQuery,
state.filters,
state.columns,
state.sort,
]);
@ -208,13 +205,16 @@ export function DiscoverLayout({
const mainDisplay = useMemo(() => {
if (resultState === 'none') {
const globalQueryState = data.query.getState();
return (
<DiscoverNoResults
isTimeBased={isTimeBased}
query={globalQueryState.query}
filters={globalQueryState.filters}
data={data}
error={dataState.error}
hasQuery={isOfQueryType(query) && !!query?.query}
hasFilters={hasActiveFilter(filters)}
dataView={dataView}
onDisableFilters={onDisableFilters}
/>
);
@ -257,7 +257,6 @@ export function DiscoverLayout({
dataState.error,
dataView,
expandedDoc,
filters,
inspectorAdapters,
isPlainRecord,
isTimeBased,
@ -265,7 +264,6 @@ export function DiscoverLayout({
onAddFilter,
onDisableFilters,
onFieldEdited,
query,
resetSavedSearch,
resultState,
savedSearch,

View file

@ -7,47 +7,83 @@
*/
import React from 'react';
import { ReactWrapper } from 'enzyme';
import * as RxApi from 'rxjs';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { DiscoverNoResults, DiscoverNoResultsProps } from './no_results';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import {
stubDataView,
stubDataViewWithoutTimeField,
} from '@kbn/data-views-plugin/common/data_view.stub';
import { type Filter } from '@kbn/es-query';
import { DiscoverNoResults, DiscoverNoResultsProps } from './no_results';
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
beforeEach(() => {
jest.clearAllMocks();
});
function mountAndFindSubjects(props: Omit<DiscoverNoResultsProps, 'onDisableFilters'>) {
const services = {
docLinks: {
links: {
query: {
luceneQuerySyntax: 'documentation-link',
},
jest.spyOn(RxApi, 'lastValueFrom').mockImplementation(async () => ({
rawResponse: {
aggregations: {
earliest_timestamp: {
value_as_string: '2020-09-01T08:30:00.000Z',
},
latest_timestamp: {
value_as_string: '2022-09-01T08:30:00.000Z',
},
},
};
const component = mountWithIntl(
<KibanaContextProvider services={services}>
<DiscoverNoResults onDisableFilters={() => {}} {...props} />
</KibanaContextProvider>
);
},
}));
async function mountAndFindSubjects(
props: Omit<DiscoverNoResultsProps, 'onDisableFilters' | 'data' | 'isTimeBased'>
) {
const services = createDiscoverServicesMock();
let component: ReactWrapper;
await act(async () => {
component = await mountWithIntl(
<KibanaContextProvider services={services}>
<DiscoverNoResults
data={services.data}
isTimeBased={props.dataView.isTimeBased()}
onDisableFilters={() => {}}
{...props}
/>
</KibanaContextProvider>
);
});
await new Promise((resolve) => setTimeout(resolve, 0));
await act(async () => {
await component!.update();
});
return {
mainMsg: findTestSubject(component, 'discoverNoResults').exists(),
errorMsg: findTestSubject(component, 'discoverNoResultsError').exists(),
adjustTimeRange: findTestSubject(component, 'discoverNoResultsTimefilter').exists(),
adjustSearch: findTestSubject(component, 'discoverNoResultsAdjustSearch').exists(),
adjustFilters: findTestSubject(component, 'discoverNoResultsAdjustFilters').exists(),
checkIndices: findTestSubject(component, 'discoverNoResultsCheckIndices').exists(),
disableFiltersButton: findTestSubject(component, 'discoverNoResultsDisableFilters').exists(),
mainMsg: findTestSubject(component!, 'discoverNoResults').exists(),
errorMsg: findTestSubject(component!, 'discoverNoResultsError').exists(),
adjustTimeRange: findTestSubject(component!, 'discoverNoResultsTimefilter').exists(),
adjustSearch: findTestSubject(component!, 'discoverNoResultsAdjustSearch').exists(),
adjustFilters: findTestSubject(component!, 'discoverNoResultsAdjustFilters').exists(),
checkIndices: findTestSubject(component!, 'discoverNoResultsCheckIndices').exists(),
disableFiltersButton: findTestSubject(component!, 'discoverNoResultsDisableFilters').exists(),
viewMatchesButton: findTestSubject(component!, 'discoverNoResultsViewAllMatches').exists(),
};
}
describe('DiscoverNoResults', () => {
beforeEach(() => {
(RxApi.lastValueFrom as jest.Mock).mockClear();
});
describe('props', () => {
describe('no props', () => {
test('renders default feedback', () => {
const result = mountAndFindSubjects({});
test('renders default feedback', async () => {
const result = await mountAndFindSubjects({
dataView: stubDataViewWithoutTimeField,
query: undefined,
filters: undefined,
});
expect(result).toMatchInlineSnapshot(`
Object {
"adjustFilters": false,
@ -57,14 +93,17 @@ describe('DiscoverNoResults', () => {
"disableFiltersButton": false,
"errorMsg": false,
"mainMsg": true,
"viewMatchesButton": false,
}
`);
});
});
describe('timeFieldName', () => {
test('renders time range feedback', () => {
const result = mountAndFindSubjects({
isTimeBased: true,
test('renders time range feedback', async () => {
const result = await mountAndFindSubjects({
dataView: stubDataView,
query: { language: 'lucene', query: '' },
filters: [],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -75,30 +114,42 @@ describe('DiscoverNoResults', () => {
"disableFiltersButton": false,
"errorMsg": false,
"mainMsg": true,
"viewMatchesButton": true,
}
`);
expect(RxApi.lastValueFrom).toHaveBeenCalledTimes(1);
});
});
describe('filter/query', () => {
test('shows "adjust search" message when having query', () => {
const result = mountAndFindSubjects({ hasQuery: true });
test('shows "adjust search" message when having query', async () => {
const result = await mountAndFindSubjects({
dataView: stubDataView,
query: { language: 'lucene', query: '*' },
filters: undefined,
});
expect(result).toHaveProperty('adjustSearch', true);
});
test('shows "adjust filters" message when having filters', () => {
const result = mountAndFindSubjects({ hasFilters: true });
test('shows "adjust filters" message when having filters', async () => {
const result = await mountAndFindSubjects({
dataView: stubDataView,
query: { language: 'lucene', query: '' },
filters: [{} as Filter],
});
expect(result).toHaveProperty('adjustFilters', true);
expect(result).toHaveProperty('disableFiltersButton', true);
});
});
describe('error message', () => {
test('renders error message', () => {
test('renders error message', async () => {
const error = new Error('Fatal error');
const result = mountAndFindSubjects({
isTimeBased: true,
const result = await mountAndFindSubjects({
dataView: stubDataView,
error,
query: { language: 'lucene', query: '' },
filters: [{} as Filter],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -109,6 +160,7 @@ describe('DiscoverNoResults', () => {
"disableFiltersButton": false,
"errorMsg": true,
"mainMsg": false,
"viewMatchesButton": false,
}
`);
});

View file

@ -8,60 +8,41 @@
import React, { Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { AggregateQuery, Filter, Query } from '@kbn/es-query';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { NoResultsSuggestions } from './no_results_suggestions';
import './_no_results.scss';
import { NoResultsIllustration } from './assets/no_results_illustration';
export interface DiscoverNoResultsProps {
isTimeBased?: boolean;
query: Query | AggregateQuery | undefined;
filters: Filter[] | undefined;
error?: Error;
data?: DataPublicPluginStart;
hasQuery?: boolean;
hasFilters?: boolean;
data: DataPublicPluginStart;
dataView: DataView;
onDisableFilters: () => void;
}
export function DiscoverNoResults({
isTimeBased,
query,
filters,
error,
data,
hasFilters,
hasQuery,
dataView,
onDisableFilters,
}: DiscoverNoResultsProps) {
const callOut = !error ? (
<EuiFlexItem grow={false} className="dscNoResults">
<EuiTitle className="dscNoResults__title">
<h2 data-test-subj="discoverNoResults">
<FormattedMessage
id="discover.noResults.searchExamples.noResultsMatchSearchCriteriaTitle"
defaultMessage="No results match your search criteria"
/>
</h2>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="xl" alignItems="center" direction="rowReverse" wrap>
<EuiFlexItem className="dscNoResults__illustration" grow={1}>
<NoResultsIllustration />
</EuiFlexItem>
<EuiFlexItem grow={2}>
<NoResultsSuggestions
isTimeBased={isTimeBased}
hasFilters={hasFilters}
hasQuery={hasQuery}
onDisableFilters={onDisableFilters}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem grow={false}>
<NoResultsSuggestions
isTimeBased={isTimeBased}
query={query}
filters={filters}
dataView={dataView}
onDisableFilters={onDisableFilters}
/>
</EuiFlexItem>
) : (
<EuiFlexItem grow={true} className="dscNoResults">

View file

@ -12,8 +12,8 @@ import React from 'react';
export const NoResultsIllustration = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="226"
height="166"
width="290"
height="213.01"
fill="none"
viewBox="0 0 226 166"
>

View file

@ -8,17 +8,15 @@
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiDescriptionList, EuiDescriptionListDescription } from '@elastic/eui';
import { EuiText } from '@elastic/eui';
export function NoResultsSuggestionDefault() {
return (
<EuiDescriptionList compressed>
<EuiDescriptionListDescription data-test-subj="discoverNoResultsCheckIndices">
<FormattedMessage
id="discover.noResults.noDocumentsOrCheckPermissionsDescription"
defaultMessage="Make sure you have permission to view the indices and that they contain documents."
/>
</EuiDescriptionListDescription>
</EuiDescriptionList>
<EuiText data-test-subj="discoverNoResultsCheckIndices">
<FormattedMessage
id="discover.noResults.noDocumentsOrCheckPermissionsDescription"
defaultMessage="Make sure you have permission to view the indices and that they contain documents."
/>
</EuiText>
);
}

View file

@ -8,12 +8,7 @@
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiDescriptionList,
EuiDescriptionListTitle,
EuiLink,
EuiDescriptionListDescription,
} from '@elastic/eui';
import { EuiLink, EuiText } from '@elastic/eui';
export interface NoResultsSuggestionWhenFiltersProps {
onDisableFilters: () => void;
@ -23,29 +18,21 @@ export function NoResultsSuggestionWhenFilters({
onDisableFilters,
}: NoResultsSuggestionWhenFiltersProps) {
return (
<EuiDescriptionList compressed>
<EuiDescriptionListTitle data-test-subj="discoverNoResultsAdjustFilters">
<FormattedMessage
id="discover.noResults.adjustFilters"
defaultMessage="Adjust your filters"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<FormattedMessage
id="discover.noResults.tryRemovingOrDisablingFilters"
defaultMessage="Try removing or {disablingFiltersLink}."
values={{
disablingFiltersLink: (
<EuiLink data-test-subj="discoverNoResultsDisableFilters" onClick={onDisableFilters}>
<FormattedMessage
id="discover.noResults.temporaryDisablingFiltersLinkText"
defaultMessage="temporarily disabling filters"
/>
</EuiLink>
),
}}
/>
</EuiDescriptionListDescription>
</EuiDescriptionList>
<EuiText data-test-subj="discoverNoResultsAdjustFilters">
<FormattedMessage
id="discover.noResults.suggestion.removeOrDisableFiltersText"
defaultMessage="Remove or {disableFiltersLink}"
values={{
disableFiltersLink: (
<EuiLink data-test-subj="discoverNoResultsDisableFilters" onClick={onDisableFilters}>
<FormattedMessage
id="discover.noResults.suggestion.disableFiltersLinkText"
defaultMessage="disable filters"
/>
</EuiLink>
),
}}
/>
</EuiText>
);
}

View file

@ -7,25 +7,199 @@
*/
import React from 'react';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiLink } from '@elastic/eui';
import { SyntaxExamples, SyntaxSuggestionsPopover } from './syntax_suggestions_popover';
import { type DiscoverServices } from '../../../../../build_services';
import { useDiscoverServices } from '../../../../../hooks/use_discover_services';
export function NoResultsSuggestionWhenQuery() {
return (
<EuiDescriptionList compressed>
<EuiDescriptionListTitle data-test-subj="discoverNoResultsAdjustSearch">
<FormattedMessage id="discover.noResults.adjustSearch" defaultMessage="Adjust your query" />
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
const getExamples = (
querySyntax: string | undefined,
docLinks: DiscoverServices['docLinks']
): SyntaxExamples | null => {
if (!querySyntax) {
return null;
}
if (querySyntax === 'lucene') {
return {
title: i18n.translate('discover.noResults.luceneExamples.title', {
defaultMessage: 'Lucene examples',
}),
items: [
{
label: i18n.translate(
'discover.noResults.luceneExamples.findRequestsThatContain200Text',
{
defaultMessage: 'Find requests that contain the number 200, in any field',
}
),
example: '200',
},
{
label: i18n.translate('discover.noResults.luceneExamples.find200InStatusFieldText', {
defaultMessage: 'Find 200 in the status field',
}),
example: 'status:200',
},
{
label: i18n.translate('discover.noResults.luceneExamples.findAllStatusCodesText', {
defaultMessage: 'Find all status codes between 400-499',
}),
example: 'status:[400 TO 499]',
},
{
label: i18n.translate('discover.noResults.luceneExamples.findStatusCodesWithPHPText', {
defaultMessage: 'Find status codes 400-499 with the extension php',
}),
example: 'status:[400 TO 499] AND extension:PHP',
},
{
label: i18n.translate(
'discover.noResults.luceneExamples.findStatusCodesWithPhpOrHtmlText',
{
defaultMessage: 'Find status codes 400-499 with the extension php or html',
}
),
example: 'status:[400 TO 499] AND (extension:php OR extension:html)',
},
],
footer: (
<FormattedMessage
id="discover.noResults.trySearchingForDifferentCombination"
defaultMessage="Try searching for a different combination of terms."
id="discover.noResults.luceneExamples.footerDescription"
defaultMessage="Learn more about {luceneLink}"
values={{
luceneLink: (
<EuiLink href={docLinks.links.query.luceneQuerySyntax} target="_blank">
<FormattedMessage
id="discover.noResults.luceneExamples.footerLuceneLink"
defaultMessage="query string syntax"
/>
</EuiLink>
),
}}
/>
</EuiDescriptionListDescription>
</EuiDescriptionList>
);
),
};
}
if (querySyntax === 'kuery') {
return {
title: i18n.translate('discover.noResults.kqlExamples.title', {
defaultMessage: 'KQL examples',
}),
items: [
{
label: i18n.translate('discover.noResults.kqlExamples.filterForExistingFieldsText', {
defaultMessage: 'Filter for documents where a field exists',
}),
example: 'http.request.method: *',
},
{
label: i18n.translate('discover.noResults.kqlExamples.filterForDocsThatMatchValueText', {
defaultMessage: 'Filter for documents that match a value',
}),
example: 'http.request.method: GET',
},
{
label: i18n.translate('discover.noResults.kqlExamples.filterForDocsWithinRangeText', {
defaultMessage: 'Filter for documents within a range',
}),
example: 'http.response.bytes > 10000 and http.response.bytes <= 20000',
},
{
label: i18n.translate('discover.noResults.kqlExamples.filterForDocsWithWildcardsText', {
defaultMessage: 'Filter for documents using wildcards',
}),
example: 'http.response.status_code: 4*',
},
{
label: i18n.translate('discover.noResults.kqlExamples.negatingQueryText', {
defaultMessage: 'Negating a query',
}),
example: 'NOT http.request.method: GET',
},
{
label: i18n.translate('discover.noResults.kqlExamples.combineMultipleText', {
defaultMessage: 'Combining multiple queries with AND/OR',
}),
example: 'http.request.method: GET AND http.response.status_code: 400',
},
{
label: i18n.translate('discover.noResults.kqlExamples.queryMultipleText', {
defaultMessage: 'Querying multiple values for the same field',
}),
example: 'http.request.method: (GET OR POST OR DELETE)',
},
],
footer: (
<FormattedMessage
id="discover.noResults.kqlExamples.kqlDescription"
defaultMessage="Learn more about {kqlLink}"
values={{
kqlLink: (
<EuiLink href={docLinks.links.query.kueryQuerySyntax} target="_blank">
<FormattedMessage
id="discover.noResults.kqlExamples.footerKQLLink"
defaultMessage="KQL"
/>
</EuiLink>
),
}}
/>
),
};
}
return null;
};
export interface NoResultsSuggestionWhenQueryProps {
querySyntax: string | undefined;
}
export const NoResultsSuggestionWhenQuery: React.FC<NoResultsSuggestionWhenQueryProps> = ({
querySyntax,
}) => {
const services = useDiscoverServices();
const { docLinks } = services;
const examplesMeta = getExamples(querySyntax, docLinks);
return (
<>
<EuiFlexGroup
direction="row"
alignItems="center"
gutterSize="xs"
responsive={false}
wrap={false}
css={css`
display: inline-flex;
`}
>
<EuiFlexItem grow={false}>
<EuiText data-test-subj="discoverNoResultsAdjustSearch">
{examplesMeta ? (
<FormattedMessage
id="discover.noResults.suggestion.adjustYourQueryWithExamplesText"
defaultMessage="Try a different query"
/>
) : (
<FormattedMessage
id="discover.noResults.suggestion.adjustYourQueryText"
defaultMessage="Adjust your query"
/>
)}
</EuiText>
</EuiFlexItem>
{!!examplesMeta && (
<EuiFlexItem grow={false}>
<SyntaxSuggestionsPopover meta={examplesMeta} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
);
};

View file

@ -8,27 +8,15 @@
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
import { EuiText } from '@elastic/eui';
export function NoResultsSuggestionWhenTimeRange() {
export const NoResultsSuggestionWhenTimeRange: React.FC = () => {
return (
<EuiDescriptionList compressed>
<EuiDescriptionListTitle data-test-subj="discoverNoResultsTimefilter">
<FormattedMessage
id="discover.noResults.expandYourTimeRangeTitle"
defaultMessage="Expand your time range"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<FormattedMessage
id="discover.noResults.queryMayNotMatchTitle"
defaultMessage="Try searching over a longer period of time."
/>
</EuiDescriptionListDescription>
</EuiDescriptionList>
<EuiText data-test-subj="discoverNoResultsTimefilter">
<FormattedMessage
id="discover.noResults.suggestion.expandTimeRangeText"
defaultMessage="Expand the time range"
/>
</EuiText>
);
}
};

View file

@ -6,8 +6,18 @@
* Side Public License, v 1.
*/
import React from 'react';
import { EuiSpacer } from '@elastic/eui';
import React, { useState } from 'react';
import { css } from '@emotion/react';
import { EuiEmptyPrompt, EuiButton, EuiLoadingSpinner, EuiSpacer, useEuiTheme } from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/common';
import {
isOfQueryType,
isOfAggregateQueryType,
type Query,
type AggregateQuery,
type Filter,
} from '@kbn/es-query';
import { FormattedMessage } from '@kbn/i18n-react';
import { NoResultsSuggestionDefault } from './no_results_suggestion_default';
import {
NoResultsSuggestionWhenFilters,
@ -15,41 +25,133 @@ import {
} from './no_results_suggestion_when_filters';
import { NoResultsSuggestionWhenQuery } from './no_results_suggestion_when_query';
import { NoResultsSuggestionWhenTimeRange } from './no_results_suggestion_when_time_range';
import { hasActiveFilter } from '../../layout/utils';
import { useDiscoverServices } from '../../../../../hooks/use_discover_services';
import { useFetchOccurrencesRange } from './use_fetch_occurances_range';
import { NoResultsIllustration } from './assets/no_results_illustration';
interface NoResultsSuggestionProps {
hasFilters?: boolean;
hasQuery?: boolean;
dataView: DataView;
isTimeBased?: boolean;
query: Query | AggregateQuery | undefined;
filters: Filter[] | undefined;
onDisableFilters: NoResultsSuggestionWhenFiltersProps['onDisableFilters'];
}
export function NoResultsSuggestions({
export const NoResultsSuggestions: React.FC<NoResultsSuggestionProps> = ({
dataView,
isTimeBased,
hasFilters,
hasQuery,
query,
filters,
onDisableFilters,
}: NoResultsSuggestionProps) {
}) => {
const { euiTheme } = useEuiTheme();
const services = useDiscoverServices();
const { data, uiSettings, timefilter } = services;
const hasQuery =
(isOfQueryType(query) && !!query?.query) || (!!query && isOfAggregateQueryType(query));
const hasFilters = hasActiveFilter(filters);
const [isExtending, setIsExtending] = useState<boolean>(false);
const { range: occurrencesRange, refetch } = useFetchOccurrencesRange({
dataView,
query,
filters,
services: {
data,
uiSettings,
},
});
const extendTimeRange = async () => {
setIsExtending(true);
const range = await refetch();
if (range?.from && range?.to) {
timefilter.setTime({
from: range.from,
to: range.to,
});
} else {
setIsExtending(false);
}
};
const canExtendTimeRange = Boolean(occurrencesRange?.from && occurrencesRange.to);
const canAdjustSearchCriteria = isTimeBased || hasFilters || hasQuery;
if (canAdjustSearchCriteria) {
return (
<>
{isTimeBased && <NoResultsSuggestionWhenTimeRange />}
const body = canAdjustSearchCriteria ? (
<>
<FormattedMessage
id="discover.noResults.suggestion.tryText"
defaultMessage="Here are some things to try:"
/>
<EuiSpacer size="xs" />
<ul
css={css`
display: inline-block;
`}
>
{isTimeBased && (
<li>
<NoResultsSuggestionWhenTimeRange />
</li>
)}
{hasQuery && (
<>
<EuiSpacer size="s" />
<NoResultsSuggestionWhenQuery />
</>
<li>
<NoResultsSuggestionWhenQuery
querySyntax={isOfQueryType(query) ? query.language : undefined}
/>
</li>
)}
{hasFilters && (
<>
<EuiSpacer size="s" />
<li>
<NoResultsSuggestionWhenFilters onDisableFilters={onDisableFilters} />
</>
</li>
)}
</>
);
}
</ul>
</>
) : (
<NoResultsSuggestionDefault />
);
return <NoResultsSuggestionDefault />;
}
return (
<EuiEmptyPrompt
layout="horizontal"
color="plain"
icon={<NoResultsIllustration />}
title={
<h2 data-test-subj="discoverNoResults">
<FormattedMessage
id="discover.noResults.searchExamples.noResultsMatchSearchCriteriaTitle"
defaultMessage="No results match your search&nbsp;criteria"
/>
</h2>
}
body={body}
actions={
<div
css={css`
min-block-size: ${euiTheme.size.xxl};
`}
>
{typeof occurrencesRange === 'undefined' ? (
<EuiLoadingSpinner />
) : canExtendTimeRange ? (
<EuiButton
color="primary"
fill
onClick={extendTimeRange}
isLoading={isExtending}
data-test-subj="discoverNoResultsViewAllMatches"
>
<FormattedMessage
id="discover.noResults.suggestion.viewAllMatchesButtonText"
defaultMessage="View all matches"
/>
</EuiButton>
) : null}
</div>
}
/>
);
};

View file

@ -0,0 +1,102 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import { css } from '@emotion/react';
import {
EuiBasicTable,
EuiButtonIcon,
EuiPanel,
EuiPopover,
EuiPopoverTitle,
EuiCode,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface SyntaxExample {
label: string;
example: string;
}
export interface SyntaxExamples {
title: string;
footer: React.ReactElement;
items: SyntaxExample[];
}
export interface SyntaxSuggestionsPopoverProps {
meta: SyntaxExamples;
}
export const SyntaxSuggestionsPopover: React.FC<SyntaxSuggestionsPopoverProps> = ({ meta }) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const { title, items, footer } = meta;
const helpButton = (
<EuiButtonIcon
onClick={() => setIsOpen((prev) => !prev)}
iconType="documentation"
aria-label={title}
/>
);
const columns = [
{
field: 'label',
name: i18n.translate('discover.noResults.suggestion.syntaxPopoverDescriptionHeader', {
defaultMessage: 'Description',
}),
width: '200px',
},
{
field: 'example',
name: i18n.translate('discover.noResults.suggestion.syntaxPopoverExampleHeader', {
defaultMessage: 'Example',
}),
render: (example: string) => <EuiCode>{example}</EuiCode>,
},
];
return (
<EuiPopover
button={helpButton}
isOpen={isOpen}
display="inlineBlock"
panelPaddingSize="none"
closePopover={() => setIsOpen(false)}
initialFocus="#querySyntaxBasicTableId"
>
<EuiPopoverTitle paddingSize="s">{title}</EuiPopoverTitle>
<EuiPanel
className="eui-yScroll"
css={css`
max-height: 40vh;
max-width: 500px;
`}
color="transparent"
paddingSize="s"
>
<EuiBasicTable<SyntaxExample>
id="querySyntaxBasicTableId"
tableCaption={title}
items={items}
compressed={true}
rowHeader="label"
columns={columns}
responsive
/>
</EuiPanel>
<EuiPanel color="transparent" paddingSize="s">
<EuiText size="s">{footer}</EuiText>
<EuiSpacer size="xs" />
</EuiPanel>
</EuiPopover>
);
};

View file

@ -0,0 +1,153 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import type { DataView } from '@kbn/data-plugin/common';
import type { AggregateQuery, Filter, Query } from '@kbn/es-query';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import type { AggregationsSingleMetricAggregateBase } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { buildEsQuery } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
export interface Params {
dataView?: DataView;
query?: Query | AggregateQuery;
filters?: Filter[];
services: {
data: DataPublicPluginStart;
uiSettings: IUiSettingsClient;
};
}
export interface OccurrencesRange {
from: string;
to: string;
}
export interface Result {
range: OccurrencesRange | null | undefined;
refetch: () => Promise<OccurrencesRange | null | undefined>;
}
export const useFetchOccurrencesRange = (params: Params): Result => {
const data = params.services.data;
const uiSettings = params.services.uiSettings;
const [range, setRange] = useState<OccurrencesRange | null | undefined>(undefined);
const abortControllerRef = useRef<AbortController | null>(null);
const mountedRef = useRef<boolean>(true);
const fetchOccurrences = useCallback(
async (dataView?: DataView, query?: Query | AggregateQuery, filters?: Filter[]) => {
let occurrencesRange = null;
if (!dataView?.timeFieldName || !query || !mountedRef.current) {
return null;
}
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
try {
const dslQuery = buildEsQuery(
dataView,
query ?? [],
filters ?? [],
getEsQueryConfig(uiSettings)
);
occurrencesRange = await fetchDocumentsTimeRange({
data,
dataView,
dslQuery,
abortSignal: abortControllerRef.current?.signal,
});
} catch (error) {
//
}
if (mountedRef.current) {
setRange(occurrencesRange);
}
return occurrencesRange;
},
[abortControllerRef, setRange, mountedRef, data, uiSettings]
);
useEffect(() => {
return () => {
mountedRef.current = false;
abortControllerRef.current?.abort();
};
}, [abortControllerRef, mountedRef]);
useEffect(() => {
fetchOccurrences(params.dataView, params.query, params.filters);
}, [fetchOccurrences, params.query, params.filters, params.dataView]);
return {
range,
refetch: () => fetchOccurrences(params.dataView, params.query, params.filters),
};
};
async function fetchDocumentsTimeRange({
data,
dataView,
dslQuery,
abortSignal,
}: {
data: DataPublicPluginStart;
dataView: DataView;
dslQuery?: object;
abortSignal?: AbortSignal;
}): Promise<OccurrencesRange | null> {
if (!dataView?.timeFieldName) {
return null;
}
const result = await lastValueFrom(
data.search.search(
{
params: {
index: dataView.title,
size: 0,
body: {
query: dslQuery ?? { match_all: {} },
aggs: {
earliest_timestamp: {
min: {
field: dataView.timeFieldName,
},
},
latest_timestamp: {
max: {
field: dataView.timeFieldName,
},
},
},
},
},
},
{
abortSignal,
}
)
);
const earliestTimestamp = (
result.rawResponse?.aggregations?.earliest_timestamp as AggregationsSingleMetricAggregateBase
)?.value_as_string;
const latestTimestamp = (
result.rawResponse?.aggregations?.latest_timestamp as AggregationsSingleMetricAggregateBase
)?.value_as_string;
return earliestTimestamp && latestTimestamp
? { from: earliestTimestamp, to: latestTimestamp }
: null;
}

View file

@ -159,6 +159,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const isVisible = await PageObjects.discover.hasNoResultsTimepicker();
expect(isVisible).to.be(true);
});
it('should show matches when time range is expanded', async () => {
await PageObjects.discover.expandTimeRangeAsSuggestedInNoResultsMessage();
await PageObjects.discover.waitUntilSearchingHasFinished();
await retry.try(async function () {
expect(await PageObjects.discover.hasNoResults()).to.be(false);
expect(await PageObjects.discover.getHitCountInt()).to.be.above(0);
});
});
});
describe('nested query', () => {

View file

@ -457,6 +457,13 @@ export class DiscoverPageObject extends FtrService {
return await this.testSubjects.exists('discoverNoResultsTimefilter');
}
public async expandTimeRangeAsSuggestedInNoResultsMessage() {
await this.retry.waitFor('the button before pressing it', async () => {
return await this.testSubjects.exists('discoverNoResultsViewAllMatches');
});
return await this.testSubjects.click('discoverNoResultsViewAllMatches');
}
public async getSidebarAriaDescription(): Promise<string> {
return await (
await this.testSubjects.find('fieldListGrouped__ariaDescription')

View file

@ -2079,7 +2079,6 @@
"discover.gridSampleSize.description": "Vous voyez les {sampleSize} premiers échantillons de documents qui correspondent à votre recherche. Pour modifier cette valeur, accédez à {advancedSettingsLink}.",
"discover.howToSeeOtherMatchingDocumentsDescription": "Voici les {sampleSize} premiers documents correspondant à votre recherche. Veuillez affiner celle-ci pour en voir plus.",
"discover.noMatchRoute.bannerText": "L'application Discover ne reconnaît pas cet itinéraire : {route}",
"discover.noResults.tryRemovingOrDisablingFilters": "Essayez de supprimer ou de {disablingFiltersLink}.",
"discover.pageTitleWithSavedSearch": "Discover - {savedSearchTitle}",
"discover.savedSearchAliasMatchRedirect.objectNoun": "Recherche {savedSearch}",
"discover.savedSearchURLConflictCallout.objectNoun": "Recherche {savedSearch}",
@ -2321,15 +2320,9 @@
"discover.localMenu.shareSearchDescription": "Partager la recherche",
"discover.localMenu.shareTitle": "Partager",
"discover.noMatchRoute.bannerTitleText": "Page introuvable",
"discover.noResults.adjustFilters": "Modifiez les filtres.",
"discover.noResults.adjustSearch": "Modifiez la requête.",
"discover.noResults.expandYourTimeRangeTitle": "Étendre la plage temporelle",
"discover.noResults.noDocumentsOrCheckPermissionsDescription": "Assurez-vous de disposer de l'autorisation d'afficher les index et vérifiez qu'ils contiennent des documents.",
"discover.noResults.queryMayNotMatchTitle": "Essayez de rechercher sur une période plus longue.",
"discover.noResults.searchExamples.noResultsBecauseOfError": "Une erreur sest produite lors de la récupération des résultats de recherche.",
"discover.noResults.searchExamples.noResultsMatchSearchCriteriaTitle": "Aucun résultat ne correspond à vos critères de recherche.",
"discover.noResults.temporaryDisablingFiltersLinkText": "désactiver temporairement les filtres",
"discover.noResults.trySearchingForDifferentCombination": "Essayez de rechercher une autre combinaison de termes.",
"discover.noResultsFound": "Résultat introuvable",
"discover.notifications.invalidTimeRangeText": "La plage temporelle spécifiée n'est pas valide (de : \"{from}\" à \"{to}\").",
"discover.notifications.invalidTimeRangeTitle": "Plage temporelle non valide",

View file

@ -2077,7 +2077,6 @@
"discover.gridSampleSize.description": "検索と一致する最初の{sampleSize}ドキュメントを表示しています。この値を変更するには、{advancedSettingsLink}に移動してください。",
"discover.howToSeeOtherMatchingDocumentsDescription": "これらは検索条件に一致した初めの {sampleSize} 件のドキュメントです。他の結果を表示するには検索条件を絞ってください。",
"discover.noMatchRoute.bannerText": "Discoverアプリケーションはこのルート{route}を認識できません",
"discover.noResults.tryRemovingOrDisablingFilters": "削除または{disablingFiltersLink}してください。",
"discover.pageTitleWithSavedSearch": "Discover - {savedSearchTitle}",
"discover.savedSearchAliasMatchRedirect.objectNoun": "{savedSearch}検索",
"discover.savedSearchURLConflictCallout.objectNoun": "{savedSearch}検索",
@ -2319,15 +2318,9 @@
"discover.localMenu.shareSearchDescription": "検索を共有します",
"discover.localMenu.shareTitle": "共有",
"discover.noMatchRoute.bannerTitleText": "ページが見つかりません",
"discover.noResults.adjustFilters": "フィルターを調整",
"discover.noResults.adjustSearch": "クエリを調整",
"discover.noResults.expandYourTimeRangeTitle": "時間範囲を拡大",
"discover.noResults.noDocumentsOrCheckPermissionsDescription": "インデックスと含まれるドキュメントを表示する権限がありません。",
"discover.noResults.queryMayNotMatchTitle": "期間を長くして検索を試してください。",
"discover.noResults.searchExamples.noResultsBecauseOfError": "検索結果の取得中にエラーが発生しました",
"discover.noResults.searchExamples.noResultsMatchSearchCriteriaTitle": "検索条件と一致する結果がありません。",
"discover.noResults.temporaryDisablingFiltersLinkText": "フィルターを一時的に無効にしています",
"discover.noResults.trySearchingForDifferentCombination": "別の用語の組み合わせを検索してください。",
"discover.noResultsFound": "結果が見つかりませんでした",
"discover.notifications.invalidTimeRangeText": "指定された時間範囲が無効です。(開始:'{from}'、終了:'{to}'",
"discover.notifications.invalidTimeRangeTitle": "無効な時間範囲",

View file

@ -2081,7 +2081,6 @@
"discover.gridSampleSize.description": "您正查看与您的搜索相匹配的前 {sampleSize} 个文档。要更改此值,请转到{advancedSettingsLink}。",
"discover.howToSeeOtherMatchingDocumentsDescription": "下面是与您的搜索匹配的前 {sampleSize} 个文档,请优化您的搜索以查看其他文档。",
"discover.noMatchRoute.bannerText": "Discover 应用程序无法识别此路由:{route}",
"discover.noResults.tryRemovingOrDisablingFilters": "尝试删除或{disablingFiltersLink}。",
"discover.pageTitleWithSavedSearch": "Discover - {savedSearchTitle}",
"discover.savedSearchAliasMatchRedirect.objectNoun": "{savedSearch} 搜索",
"discover.savedSearchURLConflictCallout.objectNoun": "{savedSearch} 搜索",
@ -2323,15 +2322,9 @@
"discover.localMenu.shareSearchDescription": "共享搜索",
"discover.localMenu.shareTitle": "共享",
"discover.noMatchRoute.bannerTitleText": "未找到页面",
"discover.noResults.adjustFilters": "调整您的筛选",
"discover.noResults.adjustSearch": "调整您的查询",
"discover.noResults.expandYourTimeRangeTitle": "展开时间范围",
"discover.noResults.noDocumentsOrCheckPermissionsDescription": "确保您有权查看索引并且它们包含文档。",
"discover.noResults.queryMayNotMatchTitle": "尝试搜索更长的时间段。",
"discover.noResults.searchExamples.noResultsBecauseOfError": "检索搜索结果时遇到问题",
"discover.noResults.searchExamples.noResultsMatchSearchCriteriaTitle": "没有任何结果匹配您的搜索条件",
"discover.noResults.temporaryDisablingFiltersLinkText": "正临时禁用筛选",
"discover.noResults.trySearchingForDifferentCombination": "尝试搜索不同的词组合。",
"discover.noResultsFound": "找不到结果",
"discover.notifications.invalidTimeRangeText": "提供的时间范围无效。(自:“{from}”,至:“{to}”)",
"discover.notifications.invalidTimeRangeTitle": "时间范围无效",