mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
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:
parent
b601f9af7b
commit
4b2728806a
37 changed files with 900 additions and 743 deletions
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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)' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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.',
|
||||
});
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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)' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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([]);
|
||||
|
|
|
@ -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 & {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }
|
||||
),
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -72,6 +72,7 @@ describe('ContextAppContent test', () => {
|
|||
isLegacy: isLegacy ?? true,
|
||||
setAppState: () => {},
|
||||
addFilter: () => {},
|
||||
interceptedWarnings: [],
|
||||
} as unknown as ContextAppContentProps;
|
||||
|
||||
const component = mountWithIntl(
|
||||
|
|
|
@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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 ?? []} />
|
||||
</>
|
||||
),
|
||||
[
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -11,13 +11,15 @@
|
|||
}
|
||||
|
||||
.lnsWorkspaceWarningList__item {
|
||||
padding: $euiSize;
|
||||
|
||||
& + & {
|
||||
border-top: $euiBorderThin;
|
||||
}
|
||||
}
|
||||
|
||||
.lnsWorkspaceWarningList__textItem {
|
||||
padding: $euiSize;
|
||||
}
|
||||
|
||||
.lnsWorkspaceWarningList__description {
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue