Update incomplete data messaging (#169578)

Closes https://github.com/elastic/kibana/issues/167906

PR breaks monolith component `<SearchResponseWarnings/>` into 3 separate
components: `<SearchResponseWarningsBadge/>`,
`<SearchResponseWarningsCallout/>`, and
`<SearchResponseWarningsEmptyPrompt/>`. These components are designed to
display a single messages when provided warnings from multiple requests
and display better messaging around partial results. PR also removes
`message` from `SearchResponseWarning` type.

Collaborated with @gchaps on copy.

### Test setup
1. install sample web logs data set
2. install sample flights data set
3. Create data view.
    1. Set **Index pattern** to `kibana_sample_data*`
    2. Set **Time field** to `timestamp`
4. Open discover
5. Select **kibana_sample_data*** data view
6. set time range to last 24 hours
7. Add filter
    ```
    {
      "error_query": {
        "indices": [
          {
            "error_type": "exception",
            "message": "shard failure message 123",
            "name": "kibana_sample_data_logs",
            "shard_ids": [
              0
            ]
          }
        ]
      }
    }
    ```
8) save search as **kibana_sample_data***

#### Search response warnings callout
1. Open saved search created in test setup
<img width="500" alt="Screenshot 2023-10-24 at 8 49 19 AM"
src="867cff58-c201-4a6b-b049-7136b43d053f">
2. Click "expand" icon on left of first row in documents table
3. Click "Surrounding documents"
4. Re-enable "kibana_sample_data_logs failure" filter
<img width="500" alt="Screenshot 2023-10-24 at 8 51 22 AM"
src="a50cf033-64de-4909-a47d-6ee07bb915ea">

#### Search response warnings empty prompt
1. Open saved search created in test setup
2. Add filter `DistanceKilometers is -1`
<img width="500" alt="Screenshot 2023-10-24 at 8 44 13 AM"
src="e3ae0fac-8bda-4cad-b079-8ace4e01b786">

#### Search response warnings badge
1. create new dashboard
2. add saved search created during test setup
<img width="500" alt="Screenshot 2023-10-26 at 9 15 21 AM"
src="0066e3e2-953b-4631-a7aa-f389f7e6dbfc">


#### Search response warnings toast
1. create new table aggregation visualization
2. Use saved search created during test setup as source
<img width="500" alt="Screenshot 2023-10-24 at 2 59 41 PM"
src="58aab97e-71d9-49d9-bd67-73484ec54751">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2023-10-27 13:20:00 -06:00 committed by GitHub
parent b601f9af7b
commit 4b2728806a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 900 additions and 743 deletions

View file

@ -9,10 +9,11 @@
export type { SearchResponseWarning, WarningHandlerCallback } from './src/types';
export {
SearchResponseWarnings,
type SearchResponseWarningsProps,
SearchResponseWarningsBadge,
SearchResponseWarningsBadgePopoverContent,
SearchResponseWarningsCallout,
SearchResponseWarningsEmptyPrompt,
} from './src/components/search_response_warnings';
export { ViewWarningButton } from './src/components/view_warning_button';
export { handleWarnings } from './src/handle_warnings';
export { hasUnsupportedDownsampledAggregationFailure } from './src/has_unsupported_downsampled_aggregation_failure';

View file

@ -10,7 +10,7 @@ import type { SearchResponseWarning } from '../types';
export const searchResponseIncompleteWarningLocalCluster: SearchResponseWarning = {
type: 'incomplete',
message: 'The data might be incomplete or wrong.',
requestName: 'My request',
clusters: {
'(local)': {
status: 'partial',

View file

@ -1,176 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SearchResponseWarnings renders "badge" correctly 1`] = `
<div
class="euiPopover emotion-euiPopover"
>
<div
class="euiPopover__anchor css-16vtueo-render"
>
<span
class="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
>
<button
class="euiButton emotion-euiButtonDisplay-s-EuiButton"
data-test-subj="test2_trigger"
title="1 warning"
type="button"
>
<span
class="emotion-euiButtonDisplayContent"
>
<span
class="emotion-EuiIcon"
data-euiicon-type="warning"
/>
1
</span>
</button>
</span>
</div>
</div>
`;
exports[`SearchResponseWarnings renders "callout" correctly 1`] = `
<div>
<ul
class="eui-yScroll"
css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)."
>
<li>
<div
class="euiPanel euiPanel--warning euiPanel--paddingSmall euiCallOut euiCallOut--warning emotion-euiPanel-none-s-warning"
data-test-subj="test1"
>
<p
class="euiTitle euiCallOutHeader__title emotion-euiTitle-xxs-euiCallOutHeader-warning"
>
<span
aria-hidden="true"
class="emotion-euiCallOut__icon"
color="inherit"
data-euiicon-type="warning"
/>
</p>
<div
class="euiFlexGroup emotion-euiFlexGroup-none-spaceBetween-stretch-row"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-wrap-xs-flexStart-center-row"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiText emotion-euiText-s"
data-test-subj="test1_warningTitle"
>
The data might be incomplete or wrong.
</div>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div />
</div>
</div>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<button
aria-label="Close"
class="euiButtonIcon emotion-euiButtonIcon-xs-empty-warning"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="cross"
/>
</button>
</div>
</div>
<p />
</div>
</li>
</ul>
</div>
`;
exports[`SearchResponseWarnings renders "empty_prompt" correctly 1`] = `
<div
class="euiPanel euiPanel--warning euiEmptyPrompt euiEmptyPrompt--vertical euiEmptyPrompt--paddingLarge emotion-euiPanel-m-warning"
>
<div
class="euiEmptyPrompt__main"
>
<div
class="euiEmptyPrompt__icon"
>
<span
color="warning"
data-euiicon-type="warning"
/>
</div>
<div
class="euiEmptyPrompt__content"
>
<div
class="euiEmptyPrompt__contentInner"
>
<h2
class="euiTitle emotion-euiTitle-m"
>
No results found
</h2>
<div
class="euiSpacer euiSpacer--m emotion-euiSpacer-m"
/>
<div
class="euiText emotion-euiText-m-euiTextColor-subdued"
>
<ul
class="eui-yScrollWithShadows"
css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)."
>
<li
css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)."
>
<div
class="euiFlexGroup emotion-euiFlexGroup-wrap-xs-flexStart-stretch-column"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiText emotion-euiText-m"
data-test-subj="test3_warningTitle"
>
The data might be incomplete or wrong.
</div>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<button
class="euiLink emotion-euiLink-primary"
data-test-subj="viewWarningBtn"
type="button"
>
View details
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,74 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { EuiButton, EuiIcon, EuiPopover, useEuiTheme, useEuiFontSize } from '@elastic/eui';
import { SearchResponseWarningsBadgePopoverContent } from './badge_popover_content';
import type { SearchResponseWarning } from '../../types';
interface Props {
warnings: SearchResponseWarning[];
}
export const SearchResponseWarningsBadge = (props: Props) => {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { euiTheme } = useEuiTheme();
const xsFontSize = useEuiFontSize('xs').fontSize;
if (!props.warnings.length) {
return null;
}
return (
<EuiPopover
panelPaddingSize="none"
button={
<EuiButton
minWidth={0}
size="s"
color="warning"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
data-test-subj="searchResponseWarningsBadgeToogleButton"
title={i18n.translate('searchResponseWarnings.badgeButtonLabel', {
defaultMessage: '{warningCount} {warningCount, plural, one {warning} other {warnings}}',
values: {
warningCount: props.warnings.length,
},
})}
css={css`
block-size: ${euiTheme.size.l};
font-size: ${xsFontSize};
padding: 0 ${euiTheme.size.xs};
& > * {
gap: ${euiTheme.size.xs};
}
`}
>
<EuiIcon
type="warning"
css={css`
margin-left: ${euiTheme.size.xxs};
`}
/>
{props.warnings.length}
</EuiButton>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
>
<SearchResponseWarningsBadgePopoverContent
onViewDetailsClick={() => {
setIsPopoverOpen(false);
}}
warnings={props.warnings}
/>
</EuiPopover>
);
};

View file

@ -0,0 +1,119 @@
/*
* 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 from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { SearchResponseWarningsBadgePopoverContent } from './badge_popover_content';
import type { SearchResponseWarning } from '../../types';
describe('SearchResponseWarningsBadgePopoverContent', () => {
describe('single warning', () => {
test('Clicking "view details" should open warning details', () => {
const mockOpenInInspector = jest.fn();
const mockOnViewDetailsClick = jest.fn();
const warnings = [
{
type: 'incomplete',
requestName: 'My request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: mockOpenInInspector,
} as SearchResponseWarning,
];
render(
<SearchResponseWarningsBadgePopoverContent
onViewDetailsClick={mockOnViewDetailsClick}
warnings={warnings}
/>
);
const viewDetailsButton = screen.getByRole('button');
fireEvent.click(viewDetailsButton);
expect(mockOpenInInspector).toHaveBeenCalled();
expect(mockOnViewDetailsClick).toHaveBeenCalled();
});
});
describe('multiple warnings', () => {
const request1MockOpenInInspector = jest.fn();
const request2MockOpenInInspector = jest.fn();
const warnings = [
{
type: 'incomplete',
requestName: 'My first request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: request1MockOpenInInspector,
} as SearchResponseWarning,
{
type: 'incomplete',
requestName: 'My second request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: request2MockOpenInInspector,
} as SearchResponseWarning,
];
beforeEach(() => {
request1MockOpenInInspector.mockReset();
request2MockOpenInInspector.mockReset();
});
test('Clicking "view details" should open content panel with button to view details for each warning', () => {
const mockOnViewDetailsClick = jest.fn();
render(
<SearchResponseWarningsBadgePopoverContent
onViewDetailsClick={mockOnViewDetailsClick}
warnings={warnings}
/>
);
const viewDetailsButton = screen.getByRole('button');
fireEvent.click(viewDetailsButton);
expect(request1MockOpenInInspector).not.toHaveBeenCalled();
expect(request2MockOpenInInspector).not.toHaveBeenCalled();
expect(mockOnViewDetailsClick).not.toHaveBeenCalled();
const openRequest1Button = screen.getByRole('button', { name: 'My first request' });
fireEvent.click(openRequest1Button);
expect(request1MockOpenInInspector).toHaveBeenCalled();
expect(mockOnViewDetailsClick).toHaveBeenCalled();
expect(request2MockOpenInInspector).not.toHaveBeenCalled();
});
test('Should ensure unique request names by numbering duplicate request names', () => {
const warningsWithDuplicateRequestNames = warnings.map((warning) => {
return {
...warning,
requestName: 'Request',
};
});
render(
<SearchResponseWarningsBadgePopoverContent warnings={warningsWithDuplicateRequestNames} />
);
const viewDetailsButton = screen.getByRole('button');
fireEvent.click(viewDetailsButton);
screen.getByRole('button', { name: 'Request' });
screen.getByRole('button', { name: 'Request (2)' });
});
});
});

View file

@ -0,0 +1,85 @@
/*
* 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 {
EuiButtonEmpty,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPanel,
EuiText,
} from '@elastic/eui';
import { getWarningsDescription, getWarningsTitle, viewDetailsLabel } from './i18n_utils';
import type { SearchResponseWarning } from '../../types';
const WARNING_PANEL_ID = 0;
const VIEW_DETAILS_PANEL_ID = 1;
interface Props {
onViewDetailsClick?: () => void;
warnings: SearchResponseWarning[];
}
export const SearchResponseWarningsBadgePopoverContent = (props: Props) => {
const [openPanel, setOpenPanel] = useState(WARNING_PANEL_ID);
const requestNameMap = new Map<string, number>();
return (
<div className="euiContextMenu">
{openPanel === VIEW_DETAILS_PANEL_ID ? (
<EuiContextMenuPanel
items={props.warnings.map((warning) => {
const count = requestNameMap.has(warning.requestName)
? requestNameMap.get(warning.requestName)! + 1
: 1;
const uniqueRequestName =
count > 1 ? `${warning.requestName} (${count})` : warning.requestName;
requestNameMap.set(warning.requestName, count);
return (
<EuiContextMenuItem
key={uniqueRequestName}
onClick={() => {
props.onViewDetailsClick?.();
warning.openInInspector();
}}
>
{uniqueRequestName}
</EuiContextMenuItem>
);
})}
onClose={() => {
setOpenPanel(WARNING_PANEL_ID);
}}
title={viewDetailsLabel}
/>
) : (
<EuiContextMenuPanel title={getWarningsTitle(props.warnings)}>
<EuiPanel color="transparent" paddingSize="s">
<EuiText size="s">{getWarningsDescription(props.warnings)}</EuiText>
<EuiButtonEmpty
color="primary"
flush="left"
iconSide={props.warnings.length > 1 ? 'right' : undefined}
iconType={props.warnings.length > 1 ? 'arrowRight' : undefined}
onClick={() => {
if (props.warnings.length > 1) {
setOpenPanel(VIEW_DETAILS_PANEL_ID);
} else {
props.onViewDetailsClick?.();
props.warnings[0].openInInspector();
}
}}
>
{viewDetailsLabel}
</EuiButtonEmpty>
</EuiPanel>
</EuiContextMenuPanel>
)}
</div>
);
};

View file

@ -0,0 +1,40 @@
/*
* 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 from 'react';
import { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { ViewDetailsPopover } from './view_details_popover';
import { getWarningsDescription, getWarningsTitle } from './i18n_utils';
import type { SearchResponseWarning } from '../../types';
interface Props {
warnings: SearchResponseWarning[];
}
export const SearchResponseWarningsCallout = (props: Props) => {
if (!props.warnings.length) {
return null;
}
return (
<EuiCallOut
title={getWarningsTitle(props.warnings)}
color="warning"
iconType="warning"
size="s"
data-test-subj="searchResponseWarningsCallout"
>
<EuiFlexGroup gutterSize="xs" alignItems="center" direction="row">
<EuiFlexItem grow={false}>{getWarningsDescription(props.warnings)}</EuiFlexItem>
<EuiFlexItem grow={false}>
<ViewDetailsPopover displayAsLink={true} warnings={props.warnings} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
);
};

View file

@ -0,0 +1,37 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import { EuiEmptyPrompt } from '@elastic/eui';
import { ViewDetailsPopover } from './view_details_popover';
import { getWarningsDescription } from './i18n_utils';
import type { SearchResponseWarning } from '../../types';
interface Props {
warnings: SearchResponseWarning[];
}
export const SearchResponseWarningsEmptyPrompt = (props: Props) => {
return (
<EuiEmptyPrompt
iconType="warning"
color="warning"
title={
<h2>
{i18n.translate('searchResponseWarnings.noResultsTitle', {
defaultMessage: 'No results found',
})}
</h2>
}
body={getWarningsDescription(props.warnings)}
actions={<ViewDetailsPopover warnings={props.warnings} />}
data-test-subj="searchResponseWarningsEmptyPrompt"
/>
);
};

View file

@ -0,0 +1,130 @@
/*
* 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 { getWarningsTitle, getWarningsDescription } from './i18n_utils';
import type { SearchResponseWarning } from '../../types';
describe('getWarningsTitle', () => {
test('Should show title for single non-successful cluster', () => {
const warnings = [
{
type: 'incomplete',
requestName: 'My request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: () => {},
} as SearchResponseWarning,
];
expect(getWarningsTitle(warnings)).toEqual('Problem with 1 cluster');
});
test('Should show title for multiple non-successful cluster', () => {
const warnings = [
{
type: 'incomplete',
requestName: 'My request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
remote2: {
status: 'skipped',
indices: '',
timed_out: false,
},
},
openInInspector: () => {},
} as SearchResponseWarning,
];
expect(getWarningsTitle(warnings)).toEqual('Problem with 2 clusters');
});
test('Should show title for multiple requests', () => {
const warnings = [
{
type: 'incomplete',
requestName: 'My request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: () => {},
} as SearchResponseWarning,
{
type: 'incomplete',
requestName: 'My request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: () => {},
} as SearchResponseWarning,
];
expect(getWarningsTitle(warnings)).toEqual('Problem with 1 cluster in 2 requests');
});
});
describe('getWarningsDescription', () => {
test('Should show description for single non-successful cluster', () => {
const warnings = [
{
type: 'incomplete',
requestName: 'My request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: () => {},
} as SearchResponseWarning,
];
expect(getWarningsDescription(warnings)).toEqual(
'This cluster had issues returning data and results might be incomplete.'
);
});
test('Should show description for multiple non-successful cluster', () => {
const warnings = [
{
type: 'incomplete',
requestName: 'My request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
remote2: {
status: 'skipped',
indices: '',
timed_out: false,
},
},
openInInspector: () => {},
} as SearchResponseWarning,
];
expect(getWarningsDescription(warnings)).toEqual(
'These clusters had issues returning data and results might be incomplete.'
);
});
});

View file

@ -0,0 +1,57 @@
/*
* 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 { i18n } from '@kbn/i18n';
import type { SearchResponseWarning } from '../../types';
export const viewDetailsLabel = i18n.translate('searchResponseWarnings.viewDetailsButtonLabel', {
defaultMessage: 'View details',
description: 'View warning details button label',
});
export function getNonSuccessfulClusters(warnings: SearchResponseWarning[]) {
const nonSuccessfulClusters = new Set<string>();
warnings.forEach((warning) => {
Object.keys(warning.clusters).forEach((clusterName) => {
if (warning.clusters[clusterName].status !== 'successful') {
nonSuccessfulClusters.add(clusterName);
}
});
});
return nonSuccessfulClusters;
}
export function getWarningsTitle(warnings: SearchResponseWarning[]) {
const nonSuccessfulClusters = getNonSuccessfulClusters(warnings);
const clustersClause = i18n.translate('searchResponseWarnings.title.clustersClause', {
defaultMessage:
'Problem with {nonSuccessfulClustersCount} {nonSuccessfulClustersCount, plural, one {cluster} other {clusters}}',
values: { nonSuccessfulClustersCount: nonSuccessfulClusters.size },
});
return warnings.length <= 1
? clustersClause
: i18n.translate('searchResponseWarnings.title.clustersClauseAndRequestsClause', {
defaultMessage: '{clustersClause} in {requestsCount} requests',
values: {
clustersClause,
requestsCount: warnings.length,
},
});
}
export function getWarningsDescription(warnings: SearchResponseWarning[]) {
const nonSuccessfulClusters = getNonSuccessfulClusters(warnings);
return nonSuccessfulClusters.size <= 1
? i18n.translate('searchResponseWarnings.description.singleCluster', {
defaultMessage: 'This cluster had issues returning data and results might be incomplete.',
})
: i18n.translate('searchResponseWarnings.description.multipleClusters', {
defaultMessage: 'These clusters had issues returning data and results might be incomplete.',
});
}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
export {
SearchResponseWarnings,
type SearchResponseWarningsProps,
} from './search_response_warnings';
export { SearchResponseWarningsBadge } from './badge';
export { SearchResponseWarningsBadgePopoverContent } from './badge_popover_content';
export { SearchResponseWarningsCallout } from './callout';
export { SearchResponseWarningsEmptyPrompt } from './empty_prompt';

View file

@ -1,49 +0,0 @@
/*
* 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 from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { SearchResponseWarnings } from './search_response_warnings';
import { searchResponseIncompleteWarningLocalCluster } from '../../__mocks__/search_response_warnings';
const interceptedWarnings = [searchResponseIncompleteWarningLocalCluster];
describe('SearchResponseWarnings', () => {
it('renders "callout" correctly', () => {
const component = mountWithIntl(
<SearchResponseWarnings
variant="callout"
interceptedWarnings={interceptedWarnings}
data-test-subj="test1"
/>
);
expect(component.render()).toMatchSnapshot();
});
it('renders "badge" correctly', () => {
const component = mountWithIntl(
<SearchResponseWarnings
variant="badge"
interceptedWarnings={interceptedWarnings}
data-test-subj="test2"
/>
);
expect(component.render()).toMatchSnapshot();
});
it('renders "empty_prompt" correctly', () => {
const component = mountWithIntl(
<SearchResponseWarnings
variant="empty_prompt"
interceptedWarnings={interceptedWarnings}
data-test-subj="test3"
/>
);
expect(component.render()).toMatchSnapshot();
});
});

View file

@ -1,313 +0,0 @@
/*
* 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, { PropsWithChildren, useEffect, useState } from 'react';
import {
EuiCallOut,
EuiEmptyPrompt,
EuiText,
EuiTextProps,
EuiFlexGroup,
EuiFlexGroupProps,
EuiFlexItem,
EuiToolTip,
EuiButton,
EuiIcon,
EuiPopover,
useEuiTheme,
useEuiFontSize,
EuiButtonIcon,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { ViewWarningButton } from '../view_warning_button';
import type { SearchResponseWarning } from '../../types';
/**
* SearchResponseWarnings component props
*/
export interface SearchResponseWarningsProps {
/**
* An array of warnings
*/
interceptedWarnings?: SearchResponseWarning[];
/**
* View variant
*/
variant: 'callout' | 'badge' | 'empty_prompt';
/**
* Custom data-test-subj value
*/
'data-test-subj': string;
}
/**
* SearchResponseWarnings component
* @param interceptedWarnings
* @param variant
* @param dataTestSubj
* @constructor
*/
export const SearchResponseWarnings = ({
interceptedWarnings,
variant,
'data-test-subj': dataTestSubj,
}: SearchResponseWarningsProps) => {
const { euiTheme } = useEuiTheme();
const xsFontSize = useEuiFontSize('xs').fontSize;
const [isCalloutVisibleMap, setIsCalloutVisibleMap] = useState<Record<number, boolean>>({});
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
useEffect(() => {
setIsCalloutVisibleMap({});
}, [interceptedWarnings, setIsCalloutVisibleMap]);
if (!interceptedWarnings?.length) {
return null;
}
if (variant === 'callout') {
return (
<div>
<ul
className="eui-yScroll"
css={css`
max-height: calc(${euiTheme.size.base} * 10);
overflow: auto;
list-style: none;
`}
>
{interceptedWarnings.map((warning, index) => {
if (isCalloutVisibleMap[index] === false) {
return null;
}
return (
<li key={`warning-${index}`}>
<EuiCallOut
title={
<CalloutTitleWrapper
onCloseCallout={() =>
setIsCalloutVisibleMap((prev) => ({ ...prev, [index]: false }))
}
>
<WarningContent
warning={warning}
groupStyles={{ alignItems: 'center', direction: 'row' }}
data-test-subj={dataTestSubj}
/>
</CalloutTitleWrapper>
}
color="warning"
iconType="warning"
size="s"
css={css`
.euiTitle {
display: flex;
align-items: center;
}
`}
data-test-subj={dataTestSubj}
/>
</li>
);
})}
</ul>
</div>
);
}
if (variant === 'empty_prompt') {
return (
<EuiEmptyPrompt
iconType="warning"
color="warning"
title={
<h2>
{i18n.translate('searchResponseWarnings.noResultsTitle', {
defaultMessage: 'No results found',
})}
</h2>
}
body={
<ul
className="eui-yScrollWithShadows"
css={css`
max-height: 50vh;
overflow: auto;
list-style: none !important;
margin: 0 !important;
padding: 0 0 ${euiTheme.size.xs} 0 !important;
text-align: left;
`}
>
{interceptedWarnings.map((warning, index) => (
<li
key={`warning-${index}`}
css={css`
padding: 0;
& + & {
margin-top: ${euiTheme.size.l};
}
`}
>
<WarningContent
warning={warning}
textSize="m"
groupStyles={{ direction: 'column' }}
data-test-subj={dataTestSubj}
/>
</li>
))}
</ul>
}
/>
);
}
if (variant === 'badge') {
const warningCount = interceptedWarnings.length;
const buttonLabel = i18n.translate('searchResponseWarnings.badgeButtonLabel', {
defaultMessage: '{warningCount} {warningCount, plural, one {warning} other {warnings}}',
values: {
warningCount,
},
});
return (
<EuiPopover
panelPaddingSize="none"
button={
<EuiToolTip content={buttonLabel}>
<EuiButton
minWidth={0}
size="s"
color="warning"
onClick={() => setIsPopoverOpen(true)}
data-test-subj={`${dataTestSubj}_trigger`}
title={buttonLabel}
css={css`
block-size: ${euiTheme.size.l};
font-size: ${xsFontSize};
padding: 0 ${euiTheme.size.xs};
& > * {
gap: ${euiTheme.size.xs};
}
`}
>
<EuiIcon
type="warning"
css={css`
margin-left: ${euiTheme.size.xxs};
`}
/>
{warningCount}
</EuiButton>
</EuiToolTip>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
>
<ul
className="eui-yScrollWithShadows"
css={css`
max-height: calc(${euiTheme.size.base} * 20);
width: calc(${euiTheme.size.base} * 16);
`}
>
{interceptedWarnings.map((warning, index) => (
<li
key={`warning-${index}`}
data-test-subj={`${dataTestSubj}_item`}
css={css`
padding: ${euiTheme.size.base};
& + & {
border-top: ${euiTheme.border.thin};
}
`}
>
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon type="warning" color="warning" />
</EuiFlexItem>
<EuiFlexItem
css={css`
overflow-wrap: break-word;
min-width: 0;
`}
>
<WarningContent
warning={warning}
groupStyles={{ direction: 'column' }}
data-test-subj={dataTestSubj}
/>
</EuiFlexItem>
</EuiFlexGroup>
</li>
))}
</ul>
</EuiPopover>
);
}
return null;
};
function WarningContent({
warning,
textSize = 's',
groupStyles,
'data-test-subj': dataTestSubj,
}: {
warning: SearchResponseWarning;
textSize?: EuiTextProps['size'];
groupStyles?: Partial<EuiFlexGroupProps>;
'data-test-subj': string;
}) {
return (
<EuiFlexGroup gutterSize="xs" {...groupStyles} wrap>
<EuiFlexItem grow={false}>
<EuiText size={textSize} data-test-subj={`${dataTestSubj}_warningTitle`}>
{warning.message}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ViewWarningButton
color="primary"
size="s"
onClick={warning.openInInspector}
isButtonEmpty={true}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
function CalloutTitleWrapper({
children,
onCloseCallout,
}: PropsWithChildren<{ onCloseCallout: () => void }>) {
return (
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate('searchResponseWarnings.closeButtonAriaLabel', {
defaultMessage: 'Close',
})}
onClick={onCloseCallout}
type="button"
iconType="cross"
color="warning"
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,128 @@
/*
* 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 from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ViewDetailsPopover } from './view_details_popover';
import type { SearchResponseWarning } from '../../types';
describe('ViewDetailsPopover', () => {
describe('single warning', () => {
const mockOpenInInspector = jest.fn();
const warnings = [
{
type: 'incomplete',
requestName: 'My request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: mockOpenInInspector,
} as SearchResponseWarning,
];
beforeEach(() => {
mockOpenInInspector.mockReset();
});
test('Clicking "view details" button should open warning details', () => {
render(<ViewDetailsPopover warnings={warnings} />);
const viewDetailsButton = screen.getByRole('button');
fireEvent.click(viewDetailsButton);
expect(mockOpenInInspector).toHaveBeenCalled();
});
test('Clicking "view details" link should open warning details', () => {
render(<ViewDetailsPopover displayAsLink={true} warnings={warnings} />);
const viewDetailsButton = screen.getByRole('button');
fireEvent.click(viewDetailsButton);
expect(mockOpenInInspector).toHaveBeenCalled();
});
});
describe('multiple warnings', () => {
const request1MockOpenInInspector = jest.fn();
const request2MockOpenInInspector = jest.fn();
const warnings = [
{
type: 'incomplete',
requestName: 'My first request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: request1MockOpenInInspector,
} as SearchResponseWarning,
{
type: 'incomplete',
requestName: 'My second request',
clusters: {
remote1: {
status: 'partial',
indices: '',
timed_out: false,
},
},
openInInspector: request2MockOpenInInspector,
} as SearchResponseWarning,
];
beforeEach(() => {
request1MockOpenInInspector.mockReset();
request2MockOpenInInspector.mockReset();
});
test('Clicking "view details" button should open popover with button to view details for each warning', () => {
render(<ViewDetailsPopover warnings={warnings} />);
const viewDetailsButton = screen.getByRole('button');
fireEvent.click(viewDetailsButton);
expect(request1MockOpenInInspector).not.toHaveBeenCalled();
expect(request2MockOpenInInspector).not.toHaveBeenCalled();
const openRequest1Button = screen.getByRole('button', { name: 'My first request' });
fireEvent.click(openRequest1Button);
expect(request1MockOpenInInspector).toHaveBeenCalled();
expect(request2MockOpenInInspector).not.toHaveBeenCalled();
});
test('Clicking "view details" link should open popover with button to view details for each warning', () => {
render(<ViewDetailsPopover displayAsLink={true} warnings={warnings} />);
const viewDetailsButton = screen.getByRole('button');
fireEvent.click(viewDetailsButton);
expect(request1MockOpenInInspector).not.toHaveBeenCalled();
expect(request2MockOpenInInspector).not.toHaveBeenCalled();
const openRequest1Button = screen.getByRole('button', { name: 'My first request' });
fireEvent.click(openRequest1Button);
expect(request1MockOpenInInspector).toHaveBeenCalled();
expect(request2MockOpenInInspector).not.toHaveBeenCalled();
});
test('Should ensure unique request names by numbering duplicate request names', () => {
const warningsWithDuplicateRequestNames = warnings.map((warning) => {
return {
...warning,
requestName: 'Request',
};
});
render(
<ViewDetailsPopover displayAsLink={true} warnings={warningsWithDuplicateRequestNames} />
);
const viewDetailsButton = screen.getByRole('button');
fireEvent.click(viewDetailsButton);
screen.getByRole('button', { name: 'Request' });
screen.getByRole('button', { name: 'Request (2)' });
});
});
});

View file

@ -0,0 +1,96 @@
/*
* 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 {
EuiButton,
EuiIcon,
EuiLink,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiPopover,
} from '@elastic/eui';
import { viewDetailsLabel } from './i18n_utils';
import type { SearchResponseWarning } from '../../types';
interface Props {
displayAsLink?: boolean;
warnings: SearchResponseWarning[];
}
export const ViewDetailsPopover = (props: Props) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
if (!props.warnings.length) {
return null;
}
if (props.warnings.length === 1) {
return props.displayAsLink ? (
<EuiLink color="primary" onClick={props.warnings[0].openInInspector}>
{viewDetailsLabel}
</EuiLink>
) : (
<EuiButton color="primary" onClick={props.warnings[0].openInInspector}>
{viewDetailsLabel}
</EuiButton>
);
}
const requestNameMap = new Map<string, number>();
const panels: EuiContextMenuPanelDescriptor[] = [
{
id: 0,
items: props.warnings.map((warning) => {
const count = requestNameMap.has(warning.requestName)
? requestNameMap.get(warning.requestName)! + 1
: 1;
const uniqueRequestName =
count > 1 ? `${warning.requestName} (${count})` : warning.requestName;
requestNameMap.set(warning.requestName, count);
return {
name: uniqueRequestName,
onClick: () => {
setIsPopoverOpen(false);
warning.openInInspector();
},
};
}),
},
];
return (
<EuiPopover
id="ViewDetailsPopover"
button={
props.displayAsLink ? (
<EuiLink color="primary" onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
<>
{viewDetailsLabel} <EuiIcon type="arrowRight" size="s" />
</>
</EuiLink>
) : (
<EuiButton
color="primary"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
iconSide="right"
iconType="arrowRight"
>
{viewDetailsLabel}
</EuiButton>
)
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
panelPaddingSize="none"
anchorPosition="downCenter"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
);
};

View file

@ -1,19 +0,0 @@
/*
* 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 from 'react';
import type { Props } from './view_warning_button';
const Fallback = () => <div />;
const LazyViewWarningButton = React.lazy(() => import('./view_warning_button'));
export const ViewWarningButton = (props: Props) => (
<React.Suspense fallback={<Fallback />}>
<LazyViewWarningButton {...props} />
</React.Suspense>
);

View file

@ -1,39 +0,0 @@
/*
* 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 from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink, EuiButton, EuiButtonProps } from '@elastic/eui';
export interface Props {
onClick: () => void;
size?: EuiButtonProps['size'];
color?: EuiButtonProps['color'];
isButtonEmpty?: boolean;
}
// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default function ViewWarningButton({
onClick,
size = 's',
color = 'warning',
isButtonEmpty = false,
}: Props) {
const Component = isButtonEmpty ? EuiLink : EuiButton;
return (
<Component color={color} size={size} onClick={onClick} data-test-subj="viewWarningBtn">
<FormattedMessage
id="searchResponseWarnings.viewDetailsButtonLabel"
defaultMessage="View details"
description="View warning details button label"
/>
</Component>
);
}

View file

@ -42,10 +42,12 @@ describe('extract search response warnings', () => {
aggregations: {},
};
expect(extractWarnings(response, mockInspectorService, mockRequestAdapter)).toEqual([
expect(
extractWarnings(response, mockInspectorService, mockRequestAdapter, 'My request')
).toEqual([
{
type: 'incomplete',
message: 'Results are partial and may be incomplete.',
requestName: 'My request',
clusters: {
'(local)': {
status: 'partial',
@ -68,10 +70,12 @@ describe('extract search response warnings', () => {
_shards: {} as estypes.ShardStatistics,
hits: { hits: [] },
};
expect(extractWarnings(response, mockInspectorService, mockRequestAdapter)).toEqual([
expect(
extractWarnings(response, mockInspectorService, mockRequestAdapter, 'My request')
).toEqual([
{
type: 'incomplete',
message: 'Results are partial and may be incomplete.',
requestName: 'My request',
clusters: {
'(local)': {
status: 'partial',
@ -97,7 +101,8 @@ describe('extract search response warnings', () => {
},
} as estypes.SearchResponse,
mockInspectorService,
mockRequestAdapter
mockRequestAdapter,
'My request'
);
expect(warnings).toEqual([]);
@ -188,10 +193,12 @@ describe('extract search response warnings', () => {
aggregations: {},
};
expect(extractWarnings(response, mockInspectorService, mockRequestAdapter)).toEqual([
expect(
extractWarnings(response, mockInspectorService, mockRequestAdapter, 'My request')
).toEqual([
{
type: 'incomplete',
message: 'Results are partial and may be incomplete.',
requestName: 'My request',
clusters: response._clusters.details,
openInInspector: expect.any(Function),
},
@ -242,10 +249,12 @@ describe('extract search response warnings', () => {
},
hits: { hits: [] },
};
expect(extractWarnings(response, mockInspectorService, mockRequestAdapter)).toEqual([
expect(
extractWarnings(response, mockInspectorService, mockRequestAdapter, 'My request')
).toEqual([
{
type: 'incomplete',
message: 'Results are partial and may be incomplete.',
requestName: 'My request',
clusters: response._clusters.details,
openInInspector: expect.any(Function),
},
@ -297,7 +306,8 @@ describe('extract search response warnings', () => {
hits: { hits: [] },
} as estypes.SearchResponse,
mockInspectorService,
mockRequestAdapter
mockRequestAdapter,
'My request'
);
expect(warnings).toEqual([]);

View file

@ -7,7 +7,6 @@
*/
import { estypes } from '@elastic/elasticsearch';
import { i18n } from '@kbn/i18n';
import type { ClusterDetails } from '@kbn/es-types';
import type { Start as InspectorStartContract, RequestAdapter } from '@kbn/inspector-plugin/public';
import type { SearchResponseWarning } from './types';
@ -19,6 +18,7 @@ export function extractWarnings(
rawResponse: estypes.SearchResponse,
inspectorService: InspectorStartContract,
requestAdapter: RequestAdapter,
requestName: string,
requestId?: string
): SearchResponseWarning[] {
const warnings: SearchResponseWarning[] = [];
@ -35,9 +35,7 @@ export function extractWarnings(
if (isPartial) {
warnings.push({
type: 'incomplete',
message: i18n.translate('searchResponseWarnings.incompleteResultsMessage', {
defaultMessage: 'Results are partial and may be incomplete.',
}),
requestName,
clusters: rawResponse._clusters
? (
rawResponse._clusters as estypes.ClusterStatistics & {

View file

@ -25,6 +25,7 @@ describe('handleWarnings', () => {
request: {} as unknown as estypes.SearchRequest,
requestAdapter: {} as unknown as RequestAdapter,
requestId: '1234',
requestName: 'My request',
response: {
timed_out: false,
_shards: {
@ -48,6 +49,7 @@ describe('handleWarnings', () => {
request: {} as unknown as estypes.SearchRequest,
requestAdapter: {} as unknown as RequestAdapter,
requestId: '1234',
requestName: 'My request',
response: {
took: 999,
timed_out: true,
@ -72,6 +74,7 @@ describe('handleWarnings', () => {
request: {} as unknown as estypes.SearchRequest,
requestAdapter: {} as unknown as RequestAdapter,
requestId: '1234',
requestName: 'My request',
response: {
took: 999,
timed_out: true,
@ -97,6 +100,7 @@ describe('handleWarnings', () => {
request: {} as unknown as estypes.SearchRequest,
requestAdapter: {} as unknown as RequestAdapter,
requestId: '1234',
requestName: 'My request',
response: {
took: 999,
timed_out: true,

View file

@ -7,7 +7,7 @@
*/
import React from 'react';
import { EuiTextAlign } from '@elastic/eui';
import { EuiButtonEmpty, EuiText } from '@elastic/eui';
import { estypes } from '@elastic/elasticsearch';
import type { NotificationsStart, ThemeServiceStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
@ -19,7 +19,11 @@ import {
WarningHandlerCallback,
} from './types';
import { extractWarnings } from './extract_warnings';
import { ViewWarningButton } from './components/view_warning_button';
import {
getWarningsDescription,
getWarningsTitle,
viewDetailsLabel,
} from './components/search_response_warnings/i18n_utils';
interface Services {
i18n: I18nStart;
@ -36,6 +40,7 @@ export function handleWarnings({
callback,
request,
requestId,
requestName,
requestAdapter,
response,
services,
@ -44,10 +49,17 @@ export function handleWarnings({
request: estypes.SearchRequest;
requestAdapter: RequestAdapter;
requestId?: string;
requestName: string;
response: estypes.SearchResponse;
services: Services;
}) {
const warnings = extractWarnings(response, services.inspector, requestAdapter, requestId);
const warnings = extractWarnings(
response,
services.inspector,
requestAdapter,
requestName,
requestId
);
if (warnings.length === 0) {
return;
}
@ -67,11 +79,21 @@ export function handleWarnings({
const [incompleteWarning] = incompleteWarnings as SearchResponseIncompleteWarning[];
services.notifications.toasts.addWarning({
title: incompleteWarning.message,
title: getWarningsTitle([incompleteWarning]),
text: toMountPoint(
<EuiTextAlign textAlign="right">
<ViewWarningButton onClick={incompleteWarning.openInInspector} />
</EuiTextAlign>,
<>
<EuiText size="s">{getWarningsDescription([incompleteWarning])}</EuiText>
<EuiButtonEmpty
color="primary"
flush="left"
onClick={() => {
incompleteWarning.openInInspector();
}}
data-test-subj="viewWarningBtn"
>
{viewDetailsLabel}
</EuiButtonEmpty>
</>,
{ theme: services.theme, i18n: services.i18n }
),
});

View file

@ -13,7 +13,7 @@ describe('hasUnsupportedDownsampledAggregationFailure', () => {
expect(
hasUnsupportedDownsampledAggregationFailure({
type: 'incomplete',
message: 'The data might be incomplete or wrong.',
requestName: 'My request',
clusters: {
'(local)': {
status: 'partial',
@ -49,7 +49,7 @@ describe('hasUnsupportedDownsampledAggregationFailure', () => {
expect(
hasUnsupportedDownsampledAggregationFailure({
type: 'incomplete',
message: 'The data might be incomplete or wrong.',
requestName: 'My request',
clusters: {
'(local)': {
status: 'partial',

View file

@ -25,9 +25,9 @@ export interface SearchResponseIncompleteWarning {
*/
type: 'incomplete';
/**
* message: human-friendly message
* requestName: human-friendly request name
*/
message: string;
requestName: string;
/**
* clusters: cluster details.
*/

View file

@ -5,11 +5,9 @@
},
"include": ["*.ts", "src/**/*", "__mocks__/**/*.ts"],
"kbn_references": [
"@kbn/test-jest-helpers",
"@kbn/i18n",
"@kbn/inspector-plugin",
"@kbn/core",
"@kbn/i18n-react",
"@kbn/es-types",
"@kbn/react-kibana-mount",
"@kbn/core-i18n-browser",

View file

@ -257,18 +257,18 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
if (!options.disableWarningToasts) {
const { rawResponse } = response;
const requestName = options.inspector?.title
? options.inspector.title
: i18n.translate('data.searchService.anonymousRequestTitle', {
defaultMessage: 'Request',
});
const requestAdapter = options.inspector?.adapter
? options.inspector?.adapter
: new RequestAdapter();
if (!options.inspector?.adapter) {
const requestResponder = requestAdapter.start(
i18n.translate('data.searchService.anonymousRequestTitle', {
defaultMessage: 'Request',
}),
{
id: request.id,
}
);
const requestResponder = requestAdapter.start(requestName, {
id: request.id,
});
requestResponder.json(request.body);
requestResponder.ok({ json: response });
}
@ -277,6 +277,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
request: request.body as estypes.SearchRequest,
requestAdapter,
requestId: request.id,
requestName,
response: rawResponse,
services: warningsServices,
});
@ -325,6 +326,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
request: request.json as estypes.SearchRequest,
requestAdapter: adapter,
requestId: request.id,
requestName: request.name,
response: rawResponse,
services: warningsServices,
});

View file

@ -72,6 +72,7 @@ describe('ContextAppContent test', () => {
isLegacy: isLegacy ?? true,
setAppState: () => {},
addFilter: () => {},
interceptedWarnings: [],
} as unknown as ContextAppContentProps;
const component = mountWithIntl(

View file

@ -15,7 +15,10 @@ import { SortDirection } from '@kbn/data-plugin/public';
import type { SortOrder } from '@kbn/saved-search-plugin/public';
import { CellActionsProvider } from '@kbn/cell-actions';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import { type SearchResponseWarning, SearchResponseWarnings } from '@kbn/search-response-warnings';
import {
type SearchResponseWarning,
SearchResponseWarningsCallout,
} from '@kbn/search-response-warnings';
import {
CONTEXT_STEP_SETTING,
DOC_HIDE_TIME_COLUMN_SETTING,
@ -50,7 +53,7 @@ export interface ContextAppContentProps {
anchorStatus: LoadingStatus;
predecessorsStatus: LoadingStatus;
successorsStatus: LoadingStatus;
interceptedWarnings: SearchResponseWarning[] | undefined;
interceptedWarnings: SearchResponseWarning[];
useNewFieldsApi: boolean;
isLegacy: boolean;
setAppState: (newState: Partial<AppState>) => void;
@ -148,13 +151,9 @@ export function ContextAppContent({
return (
<Fragment>
<WrapperWithPadding>
{!!interceptedWarnings?.length && (
{interceptedWarnings.length && (
<>
<SearchResponseWarnings
variant="callout"
interceptedWarnings={interceptedWarnings}
data-test-subj="dscContextInterceptedWarnings"
/>
<SearchResponseWarningsCallout warnings={interceptedWarnings} />
<EuiSpacer size="s" />
</>
)}

View file

@ -20,7 +20,7 @@ import { DataView } from '@kbn/data-views-plugin/public';
import { SortOrder } from '@kbn/saved-search-plugin/public';
import { CellActionsProvider } from '@kbn/cell-actions';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import { SearchResponseWarnings } from '@kbn/search-response-warnings';
import { SearchResponseWarningsCallout } from '@kbn/search-response-warnings';
import {
DataLoadingState,
useColumns,
@ -277,13 +277,7 @@ function DiscoverDocumentsComponent({
textBasedQueryColumns={documents?.textBasedQueryColumns}
selectedColumns={currentColumns}
/>
{!!documentState.interceptedWarnings?.length && (
<SearchResponseWarnings
variant="callout"
interceptedWarnings={documentState.interceptedWarnings}
data-test-subj="dscInterceptedWarningsCallout"
/>
)}
<SearchResponseWarningsCallout warnings={documentState.interceptedWarnings ?? []} />
</>
),
[

View file

@ -10,7 +10,7 @@ import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { AggregateQuery, Filter, Query } from '@kbn/es-query';
import { SearchResponseWarnings } from '@kbn/search-response-warnings';
import { SearchResponseWarningsEmptyPrompt } from '@kbn/search-response-warnings';
import { NoResultsSuggestions } from './no_results_suggestions';
import type { DiscoverStateContainer } from '../../services/discover_state';
import { useDataState } from '../../hooks/use_data_state';
@ -37,13 +37,7 @@ export function DiscoverNoResults({
const interceptedWarnings = useDataState(documents$).interceptedWarnings;
if (interceptedWarnings?.length) {
return (
<SearchResponseWarnings
variant="empty_prompt"
interceptedWarnings={interceptedWarnings}
data-test-subj="dscNoResultsInterceptedWarningsCallout"
/>
);
return <SearchResponseWarningsEmptyPrompt warnings={interceptedWarnings} />;
}
return (

View file

@ -9,7 +9,10 @@
import React from 'react';
import { css } from '@emotion/react';
import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui';
import { type SearchResponseWarning, SearchResponseWarnings } from '@kbn/search-response-warnings';
import {
type SearchResponseWarning,
SearchResponseWarningsBadge,
} from '@kbn/search-response-warnings';
import { TotalDocuments } from '../application/main/components/total_documents/total_documents';
const containerStyles = css`
@ -69,15 +72,7 @@ export const SavedSearchEmbeddableBase: React.FC<SavedSearchEmbeddableBaseProps>
{Boolean(append) && <EuiFlexItem grow={false}>{append}</EuiFlexItem>}
{Boolean(interceptedWarnings?.length) && (
<div>
<SearchResponseWarnings
variant="badge"
interceptedWarnings={interceptedWarnings}
data-test-subj="savedSearchEmbeddableWarningsCallout"
/>
</div>
)}
<SearchResponseWarningsBadge warnings={interceptedWarnings ?? []} />
</EuiFlexGroup>
);
};

View file

@ -8,7 +8,6 @@
import type { estypes } from '@elastic/elasticsearch';
import expect from '@kbn/expect';
import { asyncForEach } from '@kbn/std';
import assert from 'assert';
import type { FtrProviderContext } from '../../functional/ftr_provider_context';
import type { WebElementWrapper } from '../../functional/services/lib/web_element_wrapper';
@ -107,16 +106,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await retry.try(async () => {
const toasts = await find.allByCssSelector(toastsSelector);
expect(toasts.length).to.be(2);
const expects = ['Results are partial and may be incomplete.', 'Query result'];
await asyncForEach(toasts, async (t, index) => {
expect(await t.getVisibleText()).to.eql(expects[index]);
});
await testSubjects.click('viewWarningBtn');
});
// click "see full error" button in the toast
const [openShardModalButton] = await testSubjects.findAll('viewWarningBtn');
await openShardModalButton.click();
// request
await retry.try(async () => {
await testSubjects.click('inspectorRequestDetailRequest');
@ -164,10 +156,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await retry.try(async () => {
toasts = await find.allByCssSelector(toastsSelector);
expect(toasts.length).to.be(2);
const expects = ['Results are partial and may be incomplete.', 'Query result'];
await asyncForEach(toasts, async (t, index) => {
expect(await t.getVisibleText()).to.eql(expects[index]);
});
});
// warnings tab

View file

@ -12,14 +12,17 @@ import type { DocLinksStart, ThemeServiceStart } from '@kbn/core/public';
import { hasUnsupportedDownsampledAggregationFailure } from '@kbn/search-response-warnings';
import type { DatatableUtilitiesService } from '@kbn/data-plugin/common';
import { TimeRange } from '@kbn/es-query';
import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import { EuiLink, EuiSpacer } from '@elastic/eui';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import { groupBy, escape, uniq, uniqBy } from 'lodash';
import type { Query } from '@kbn/data-plugin/common';
import { SearchRequest } from '@kbn/data-plugin/common';
import { type SearchResponseWarning, ViewWarningButton } from '@kbn/search-response-warnings';
import {
type SearchResponseWarning,
SearchResponseWarningsBadgePopoverContent,
} from '@kbn/search-response-warnings';
import { estypes } from '@elastic/elasticsearch';
import { isQueryValid } from '@kbn/visualization-ui-components';
@ -307,19 +310,10 @@ export function getSearchWarningMessages(
displayLocations: [{ id: 'toolbar' }, { id: 'embeddableBadge' }],
shortMessage: '',
longMessage: (closePopover) => (
<>
<EuiText size="s">{warning.message}</EuiText>
<EuiSpacer size="s" />
<ViewWarningButton
onClick={() => {
closePopover();
warning.openInInspector();
}}
size="m"
color="primary"
isButtonEmpty={true}
/>
</>
<SearchResponseWarningsBadgePopoverContent
onViewDetailsClick={closePopover}
warnings={[warning]}
/>
),
} as UserMessage,
];

View file

@ -11,13 +11,15 @@
}
.lnsWorkspaceWarningList__item {
padding: $euiSize;
& + & {
border-top: $euiBorderThin;
}
}
.lnsWorkspaceWarningList__textItem {
padding: $euiSize;
}
.lnsWorkspaceWarningList__description {
overflow-wrap: break-word;
min-width: 0;

View file

@ -113,22 +113,26 @@ export const MessageList = ({
className="lnsWorkspaceWarningList__item"
data-test-subj={`lens-message-list-${message.severity}`}
>
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
{message.severity === 'error' ? (
<EuiIcon type="error" color="danger" />
) : (
<EuiIcon type="alert" color="warning" />
)}
</EuiFlexItem>
<EuiFlexItem grow={1} className="lnsWorkspaceWarningList__description">
<EuiText size="s">
{typeof message.longMessage === 'function'
? message.longMessage(closePopover)
: message.longMessage}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
{typeof message.longMessage === 'function' ? (
message.longMessage(closePopover)
) : (
<EuiFlexGroup
gutterSize="s"
responsive={false}
className="lnsWorkspaceWarningList__textItem"
>
<EuiFlexItem grow={false}>
{message.severity === 'error' ? (
<EuiIcon type="error" color="danger" />
) : (
<EuiIcon type="alert" color="warning" />
)}
</EuiFlexItem>
<EuiFlexItem grow={1} className="lnsWorkspaceWarningList__description">
<EuiText size="s">{message.longMessage}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
)}
</li>
))}
</ul>

View file

@ -75,13 +75,8 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.common.navigateToApp('discover');
await PageObjects.discover.selectIndexPattern('logsta*');
await retry.tryForTime(20000, async function () {
// wait for shards failed message
const shardMessage = await testSubjects.getVisibleText(
'dscNoResultsInterceptedWarningsCallout_warningTitle'
);
log.debug(shardMessage);
expect(shardMessage).to.be('Results are partial and may be incomplete.');
await retry.try(async function () {
await testSubjects.existOrFail('searchResponseWarningsEmptyPrompt');
});
});
@ -104,9 +99,8 @@ export default function ({ getService, getPageObjects }) {
await dashboardAddPanel.addSavedSearch('search with warning');
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.tryForTime(20000, async function () {
// wait for shards failed message
await testSubjects.existOrFail('savedSearchEmbeddableWarningsCallout_trigger');
await retry.try(async function () {
await testSubjects.existOrFail('searchResponseWarningsBadgeToogleButton');
});
});

View file

@ -7,7 +7,6 @@
import type { estypes } from '@elastic/elasticsearch';
import expect from '@kbn/expect';
import { asyncForEach } from '@kbn/std';
import assert from 'assert';
import type { WebElementWrapper } from '../../../../../../../test/functional/services/lib/web_element_wrapper';
import type { FtrProviderContext } from '../../../../ftr_provider_context';
@ -107,16 +106,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await retry.try(async () => {
const toasts = await find.allByCssSelector(toastsSelector);
expect(toasts.length).to.be(2);
const expects = ['Results are partial and may be incomplete.', 'Query result'];
await asyncForEach(toasts, async (t, index) => {
expect(await t.getVisibleText()).to.eql(expects[index]);
});
await testSubjects.click('viewWarningBtn');
});
// click "see full error" button in the toast
const [openShardModalButton] = await testSubjects.findAll('viewWarningBtn');
await openShardModalButton.click();
// request
await retry.try(async () => {
await testSubjects.click('inspectorRequestDetailRequest');
@ -164,10 +156,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await retry.try(async () => {
toasts = await find.allByCssSelector(toastsSelector);
expect(toasts.length).to.be(2);
const expects = ['Results are partial and may be incomplete.', 'Query result'];
await asyncForEach(toasts, async (t, index) => {
expect(await t.getVisibleText()).to.eql(expects[index]);
});
});
// warnings tab

View file

@ -56,7 +56,6 @@
"@kbn/es-archiver",
"@kbn/rule-data-utils",
"@kbn/rison",
"@kbn/std",
"@kbn/serverless-common-settings",
"@kbn/serverless-observability-settings",
"@kbn/serverless-search-settings",