[Discover] Move total hits counter from histogram to grid area. New controls in histogram. (#171638)

- Closes https://github.com/elastic/kibana/issues/168825
- Closes https://github.com/elastic/kibana/issues/171610
- Closes https://github.com/elastic/kibana/issues/167427
- Partially addresses https://github.com/elastic/kibana/issues/165192

## Summary

This PR moves the total hits counter closer to the grid, updates
histogram controls and introduces new panel toggle buttons for toggling
fields sidebar and histogram.

<img width="500" alt="Screenshot 2023-12-05 at 15 37 20"
src="5b9bd771-1052-4205-849f-18c21cc299b8">
<img width="500" alt="Screenshot 2023-12-05 at 15 37 29"
src="e5941b27-c497-4d7e-b461-68b66931475a">
<img width="500" alt="Screenshot 2023-12-05 at 15 37 37"
src="97abd32e-9ff2-4d9a-b7e7-b9d6d9cf64db">
<img width="500" alt="Screenshot 2023-12-05 at 15 37 50"
src="10f2b4f4-ec37-41c3-b78b-78c64e14d655">
<img width="400" alt="Screenshot 2023-12-05 at 15 37 59"
src="ef2e28b2-f6ba-4ccb-aea4-3946ba2d5839">
<img width="300" alt="Screenshot 2023-12-05 at 15 38 05"
src="07901ede-0bcb-46a6-a398-4562189fd54f">
<img width="500" alt="Screenshot 2023-12-05 at 15 40 38"
src="17830115-2111-4b8f-ae40-7b5875c06879">
<img width="500" alt="Screenshot 2023-12-05 at 15 40 56"
src="975d475b-280b-495a-b7b7-31c7ade5f21e">
<img width="500" alt="Screenshot 2023-12-05 at 15 43 08"
src="38b6053a-e260-48d8-9591-3f3409df2876">

## Testing

When testing, please check collapsing/expanding the fields sidebar and
histogram. Also for ES|QL mode with suggestions, legacy table, no
results and error prompt, Field Statistics tab, data views without a
time field, light/dark themes.

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
Co-authored-by: Davis McPhee <davis.mcphee@elastic.co>
This commit is contained in:
Julia Rechkunova 2024-01-12 11:36:27 +01:00 committed by GitHub
parent 6c36503a63
commit aa33843863
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 2440 additions and 1497 deletions

View file

@ -304,26 +304,14 @@ export const UnifiedFieldListSidebarComponent: React.FC<UnifiedFieldListSidebarP
return null;
}
const sidebarToggleButton =
typeof isSidebarCollapsed === 'boolean' && onToggleSidebar ? (
<SidebarToggleButton
buttonSize={compressed ? 's' : 'm'}
isSidebarCollapsed={isSidebarCollapsed}
onChange={onToggleSidebar}
/>
) : null;
const pageSidebarProps: Partial<EuiPageSidebarProps> = {
className: classnames('unifiedFieldListSidebar', {
'unifiedFieldListSidebar--collapsed': isSidebarCollapsed,
['unifiedFieldListSidebar--fullWidth']: fullWidth,
}),
'aria-label': i18n.translate(
'unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel',
{
defaultMessage: 'Index and fields',
}
),
'aria-label': i18n.translate('unifiedFieldList.fieldListSidebar.fieldsSidebarAriaLabel', {
defaultMessage: 'Fields',
}),
id:
stateService.creationOptions.dataTestSubj?.fieldListSidebarDataTestSubj ??
'unifiedFieldListSidebarId',
@ -332,6 +320,16 @@ export const UnifiedFieldListSidebarComponent: React.FC<UnifiedFieldListSidebarP
'unifiedFieldListSidebarId',
};
const sidebarToggleButton =
typeof isSidebarCollapsed === 'boolean' && onToggleSidebar ? (
<SidebarToggleButton
buttonSize={compressed ? 's' : 'm'}
isSidebarCollapsed={isSidebarCollapsed}
panelId={pageSidebarProps.id}
onChange={onToggleSidebar}
/>
) : null;
if (isSidebarCollapsed && sidebarToggleButton) {
return (
<EuiHideFor sizes={['xs', 's']}>

View file

@ -17,6 +17,7 @@ import React, {
} from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import useObservable from 'react-use/lib/useObservable';
import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import {
EuiBadge,
@ -30,13 +31,12 @@ import {
EuiShowFor,
EuiTitle,
} from '@elastic/eui';
import { BehaviorSubject, Observable } from 'rxjs';
import {
useExistingFieldsFetcher,
type ExistingFieldsFetcher,
} from '../../hooks/use_existing_fields';
import { useQuerySubscriber } from '../../hooks/use_query_subscriber';
import { useSidebarToggle } from '../../hooks/use_sidebar_toggle';
import { getSidebarVisibility, SidebarVisibility } from './get_sidebar_visibility';
import {
UnifiedFieldListSidebar,
type UnifiedFieldListSidebarCustomizableProps,
@ -50,7 +50,7 @@ import type {
} from '../../types';
export interface UnifiedFieldListSidebarContainerApi {
isSidebarCollapsed$: Observable<boolean>;
sidebarVisibility: SidebarVisibility;
refetchFieldsExistenceInfo: ExistingFieldsFetcher['refetchFieldsExistenceInfo'];
closeFieldListFlyout: () => void;
// no user permission or missing dataViewFieldEditor service will result in `undefined` API methods
@ -122,8 +122,14 @@ const UnifiedFieldListSidebarContainer = forwardRef<
);
const { data, dataViewFieldEditor } = services;
const [isFieldListFlyoutVisible, setIsFieldListFlyoutVisible] = useState<boolean>(false);
const { isSidebarCollapsed, onToggleSidebar } = useSidebarToggle({ stateService });
const [isSidebarCollapsed$] = useState(() => new BehaviorSubject(isSidebarCollapsed));
const [sidebarVisibility] = useState(() =>
getSidebarVisibility({
localStorageKey: stateService.creationOptions.localStorageKeyPrefix
? `${stateService.creationOptions.localStorageKeyPrefix}:sidebarClosed`
: undefined,
})
);
const isSidebarCollapsed = useObservable(sidebarVisibility.isCollapsed$, false);
const canEditDataView =
Boolean(dataViewFieldEditor?.userPermissions.editIndexPattern()) ||
@ -225,21 +231,17 @@ const UnifiedFieldListSidebarContainer = forwardRef<
};
}, []);
useEffect(() => {
isSidebarCollapsed$.next(isSidebarCollapsed);
}, [isSidebarCollapsed, isSidebarCollapsed$]);
useImperativeHandle(
componentRef,
() => ({
isSidebarCollapsed$,
sidebarVisibility,
refetchFieldsExistenceInfo,
closeFieldListFlyout,
createField: editField,
editField,
deleteField,
}),
[isSidebarCollapsed$, refetchFieldsExistenceInfo, closeFieldListFlyout, editField, deleteField]
[sidebarVisibility, refetchFieldsExistenceInfo, closeFieldListFlyout, editField, deleteField]
);
if (!dataView) {
@ -260,7 +262,7 @@ const UnifiedFieldListSidebarContainer = forwardRef<
if (stateService.creationOptions.showSidebarToggleButton) {
commonSidebarProps.isSidebarCollapsed = isSidebarCollapsed;
commonSidebarProps.onToggleSidebar = onToggleSidebar;
commonSidebarProps.onToggleSidebar = sidebarVisibility.toggle;
}
const buttonPropsToTriggerFlyout = stateService.creationOptions.buttonPropsToTriggerFlyout;

View file

@ -0,0 +1,66 @@
/*
* 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 { act } from '@testing-library/react-hooks';
import { getSidebarVisibility } from './get_sidebar_visibility';
const localStorageKey = 'test-sidebar-visibility';
describe('UnifiedFieldList getSidebarVisibility', () => {
beforeEach(() => {
localStorage.removeItem(localStorageKey);
});
it('should toggle correctly', async () => {
const state = getSidebarVisibility({ localStorageKey });
expect(state.isCollapsed$.getValue()).toBe(false);
act(() => {
state.toggle(true);
});
expect(state.isCollapsed$.getValue()).toBe(true);
expect(localStorage.getItem(localStorageKey)).toBe('true');
act(() => {
state.toggle(false);
});
expect(state.isCollapsed$.getValue()).toBe(false);
expect(localStorage.getItem(localStorageKey)).toBe('false');
});
it('should restore collapsed state and expand from it', async () => {
localStorage.setItem(localStorageKey, 'true');
const state = getSidebarVisibility({ localStorageKey });
expect(state.isCollapsed$.getValue()).toBe(true);
act(() => {
state.toggle(false);
});
expect(state.isCollapsed$.getValue()).toBe(false);
expect(localStorage.getItem(localStorageKey)).toBe('false');
});
it('should not persist if local storage key is not defined', async () => {
const state = getSidebarVisibility({ localStorageKey: undefined });
expect(state.isCollapsed$.getValue()).toBe(false);
act(() => {
state.toggle(true);
});
expect(state.isCollapsed$.getValue()).toBe(true);
expect(localStorage.getItem(localStorageKey)).toBe(null);
});
});

View file

@ -0,0 +1,59 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
export interface SidebarVisibility {
isCollapsed$: BehaviorSubject<boolean>;
toggle: (isCollapsed: boolean) => void;
}
export interface GetSidebarStateParams {
localStorageKey?: string;
}
/**
* For managing sidebar visibility state
* @param localStorageKey
*/
export const getSidebarVisibility = ({
localStorageKey,
}: GetSidebarStateParams): SidebarVisibility => {
const isCollapsed$ = new BehaviorSubject<boolean>(
localStorageKey ? getIsCollapsed(localStorageKey) : false
);
return {
isCollapsed$,
toggle: (isCollapsed) => {
isCollapsed$.next(isCollapsed);
if (localStorageKey) {
setIsCollapsed(localStorageKey, isCollapsed);
}
},
};
};
function getIsCollapsed(localStorageKey: string) {
let isCollapsed = false;
try {
isCollapsed = localStorage?.getItem(localStorageKey) === 'true';
} catch {
// nothing
}
return isCollapsed;
}
function setIsCollapsed(localStorageKey: string, isCollapsed: boolean) {
try {
localStorage?.setItem(localStorageKey, String(isCollapsed));
} catch {
// nothing
}
}

View file

@ -16,6 +16,7 @@ import { IconButtonGroup, type IconButtonGroupProps } from '@kbn/shared-ux-butto
export interface SidebarToggleButtonProps {
'data-test-subj'?: string;
isSidebarCollapsed: boolean;
panelId?: string;
buttonSize: IconButtonGroupProps['buttonSize'];
onChange: (isSidebarCollapsed: boolean) => void;
}
@ -24,12 +25,14 @@ export interface SidebarToggleButtonProps {
* A toggle button for the fields sidebar
* @param data-test-subj
* @param isSidebarCollapsed
* @param panelId
* @param onChange
* @constructor
*/
export const SidebarToggleButton: React.FC<SidebarToggleButtonProps> = ({
'data-test-subj': dataTestSubj = 'unifiedFieldListSidebar__toggle',
isSidebarCollapsed,
panelId,
buttonSize,
onChange,
}) => {
@ -49,6 +52,8 @@ export const SidebarToggleButton: React.FC<SidebarToggleButtonProps> = ({
}),
iconType: 'transitionLeftIn',
'data-test-subj': `${dataTestSubj}-expand`,
'aria-expanded': false,
'aria-controls': panelId,
onClick: () => onChange(false),
},
]
@ -59,6 +64,8 @@ export const SidebarToggleButton: React.FC<SidebarToggleButtonProps> = ({
}),
iconType: 'transitionLeftOut',
'data-test-subj': `${dataTestSubj}-collapse`,
'aria-expanded': true,
'aria-controls': panelId,
onClick: () => onChange(true),
},
]),

View file

@ -1,104 +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 { renderHook, act } from '@testing-library/react-hooks';
import { useSidebarToggle } from './use_sidebar_toggle';
import * as localStorageModule from 'react-use/lib/useLocalStorage';
jest.spyOn(localStorageModule, 'default');
describe('UnifiedFieldList useSidebarToggle', () => {
const stateService = {
creationOptions: {
originatingApp: 'test',
localStorageKeyPrefix: 'this',
},
};
beforeEach(() => {
(localStorageModule.default as jest.Mock).mockClear();
});
it('should toggle correctly', async () => {
const storeMock = jest.fn();
(localStorageModule.default as jest.Mock).mockImplementation(() => {
return [false, storeMock];
});
const { result } = renderHook(useSidebarToggle, {
initialProps: {
stateService,
},
});
expect(result.current.isSidebarCollapsed).toBe(false);
act(() => {
result.current.onToggleSidebar(true);
});
expect(result.current.isSidebarCollapsed).toBe(true);
expect(storeMock).toHaveBeenCalledWith(true);
act(() => {
result.current.onToggleSidebar(false);
});
expect(result.current.isSidebarCollapsed).toBe(false);
expect(storeMock).toHaveBeenLastCalledWith(false);
});
it('should restore collapsed state and expand from it', async () => {
const storeMock = jest.fn();
(localStorageModule.default as jest.Mock).mockImplementation(() => {
return [true, storeMock];
});
const { result } = renderHook(useSidebarToggle, {
initialProps: {
stateService,
},
});
expect(result.current.isSidebarCollapsed).toBe(true);
act(() => {
result.current.onToggleSidebar(false);
});
expect(result.current.isSidebarCollapsed).toBe(false);
expect(storeMock).toHaveBeenCalledWith(false);
});
it('should not persist if local storage key is not defined', async () => {
const storeMock = jest.fn();
(localStorageModule.default as jest.Mock).mockImplementation(() => {
return [false, storeMock];
});
const { result } = renderHook(useSidebarToggle, {
initialProps: {
stateService: {
creationOptions: {
originatingApp: 'test',
localStorageKeyPrefix: undefined,
},
},
},
});
expect(result.current.isSidebarCollapsed).toBe(false);
act(() => {
result.current.onToggleSidebar(true);
});
expect(result.current.isSidebarCollapsed).toBe(true);
expect(storeMock).not.toHaveBeenCalled();
});
});

View file

@ -1,64 +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 { useCallback, useState, useMemo } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import type { UnifiedFieldListSidebarContainerStateService } from '../types';
/**
* Hook params
*/
export interface UseSidebarToggleParams {
/**
* Service for managing the state
*/
stateService: UnifiedFieldListSidebarContainerStateService;
}
/**
* Hook result type
*/
export interface UseSidebarToggleResult {
isSidebarCollapsed: boolean;
onToggleSidebar: (isSidebarCollapsed: boolean) => void;
}
/**
* Hook for managing sidebar toggle state
* @param stateService
*/
export const useSidebarToggle = ({
stateService,
}: UseSidebarToggleParams): UseSidebarToggleResult => {
const [initialIsSidebarCollapsed, storeIsSidebarCollapsed] = useLocalStorage<boolean>(
`${stateService.creationOptions.localStorageKeyPrefix ?? 'unifiedFieldList'}:sidebarClosed`, // as legacy `discover:sidebarClosed` key
false
);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(
initialIsSidebarCollapsed ?? false
);
const onToggleSidebar = useCallback(
(isCollapsed) => {
setIsSidebarCollapsed(isCollapsed);
if (stateService.creationOptions.localStorageKeyPrefix) {
storeIsSidebarCollapsed(isCollapsed);
}
},
[
storeIsSidebarCollapsed,
setIsSidebarCollapsed,
stateService.creationOptions.localStorageKeyPrefix,
]
);
return useMemo(
() => ({ isSidebarCollapsed, onToggleSidebar }),
[isSidebarCollapsed, onToggleSidebar]
);
};

View file

@ -32,6 +32,12 @@ export interface IconButton {
title?: string;
/** Test subject for button */
'data-test-subj'?: string;
/** To disable the action **/
isDisabled?: boolean;
/** A11y for button */
'aria-expanded'?: boolean;
/** A11y for button */
'aria-controls'?: string;
}
/**
@ -44,6 +50,8 @@ export interface Props {
buttons: IconButton[];
/** Button size */
buttonSize?: EuiButtonGroupProps['buttonSize'];
/** Test subject for button group */
'data-test-subj'?: string;
}
type Option = EuiButtonGroupOptionProps & Omit<IconButton, 'label'>;
@ -51,7 +59,12 @@ type Option = EuiButtonGroupOptionProps & Omit<IconButton, 'label'>;
/**
* A group of buttons each performing an action, represented by an icon.
*/
export const IconButtonGroup = ({ buttons, legend, buttonSize = 'm' }: Props) => {
export const IconButtonGroup = ({
buttons,
legend,
buttonSize = 'm',
'data-test-subj': dataTestSubj,
}: Props) => {
const euiTheme = useEuiTheme();
const iconButtonGroupStyles = IconButtonGroupStyles(euiTheme);
@ -73,6 +86,7 @@ export const IconButtonGroup = ({ buttons, legend, buttonSize = 'm' }: Props) =>
return (
<EuiButtonGroup
data-test-subj={dataTestSubj}
buttonSize={buttonSize}
legend={legend}
options={buttonGroupOptions}

View file

@ -45,6 +45,13 @@ describe('<ToolbarButton />', () => {
component.find('button').simulate('click');
expect(mockHandler).toHaveBeenCalled();
});
test('accepts an onBlur handler', () => {
const mockHandler = jest.fn();
const component = mountWithIntl(<ToolbarButton label="Create chart" onBlur={mockHandler} />);
component.find('button').simulate('blur');
expect(mockHandler).toHaveBeenCalled();
});
});
describe('iconButton', () => {

View file

@ -23,7 +23,7 @@ type ButtonRenderStyle = 'standard' | 'iconButton';
interface ToolbarButtonCommonProps
extends Pick<
EuiButtonPropsForButton,
'onClick' | 'iconType' | 'size' | 'data-test-subj' | 'isDisabled' | 'aria-label'
'onClick' | 'onBlur' | 'iconType' | 'size' | 'data-test-subj' | 'isDisabled' | 'aria-label'
> {
/**
* Render style of the toolbar button

View file

@ -9,7 +9,11 @@
// focus states in Kibana.
:focus {
&:not([class^='eui']):not(.kbn-resetFocusState) {
@include euiFocusRing;
// The focus policy causes double focus rings to appear on EuiSelectableList
// since the focusable element does not contain a class starting with "eui".
&:not(.euiSelectableList__list > ul) {
@include euiFocusRing;
}
}
}

View file

@ -9,8 +9,9 @@
import React from 'react';
import { BehaviorSubject, of } from 'rxjs';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { dataViewMock, esHitsMock } from '@kbn/discover-utils/src/__mocks__';
import { savedSearchMock } from '../../../../__mocks__/saved_search';
import type { DataView } from '@kbn/data-views-plugin/common';
import { esHitsMock } from '@kbn/discover-utils/src/__mocks__';
import { savedSearchMockWithTimeField } from '../../../../__mocks__/saved_search';
import {
AvailableFields$,
DataDocuments$,
@ -19,7 +20,7 @@ import {
RecordRawType,
} from '../../services/discover_data_state_container';
import { discoverServiceMock } from '../../../../__mocks__/services';
import { FetchStatus } from '../../../types';
import { FetchStatus, SidebarToggleState } from '../../../types';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { buildDataTableRecord } from '@kbn/discover-utils';
@ -32,16 +33,19 @@ import { getSessionServiceMock } from '@kbn/data-plugin/public/search/session/mo
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { DiscoverMainProvider } from '../../services/discover_state_provider';
import { act } from 'react-dom/test-utils';
import { PanelsToggle } from '../../../../components/panels_toggle';
function getStateContainer(savedSearch?: SavedSearch) {
const stateContainer = getDiscoverStateMock({ isTimeBased: true, savedSearch });
const dataView = savedSearch?.searchSource?.getField('index') as DataView;
stateContainer.appState.update({
index: dataView?.id,
interval: 'auto',
hideChart: false,
});
stateContainer.internalState.transitions.setDataView(dataViewMock);
stateContainer.internalState.transitions.setDataView(dataView);
return stateContainer;
}
@ -49,7 +53,7 @@ function getStateContainer(savedSearch?: SavedSearch) {
const mountComponent = async ({
isPlainRecord = false,
storage,
savedSearch = savedSearchMock,
savedSearch = savedSearchMockWithTimeField,
searchSessionId = '123',
}: {
isPlainRecord?: boolean;
@ -58,6 +62,8 @@ const mountComponent = async ({
savedSearch?: SavedSearch;
searchSessionId?: string | null;
} = {}) => {
const dataView = savedSearch?.searchSource?.getField('index') as DataView;
let services = discoverServiceMock;
services.data.query.timefilter.timefilter.getAbsoluteTime = () => {
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
@ -85,7 +91,7 @@ const mountComponent = async ({
const documents$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: esHitsMock.map((esHit) => buildDataTableRecord(esHit, dataViewMock)),
result: esHitsMock.map((esHit) => buildDataTableRecord(esHit, dataView)),
}) as DataDocuments$;
const availableFields$ = new BehaviorSubject({
@ -115,13 +121,26 @@ const mountComponent = async ({
const props: DiscoverHistogramLayoutProps = {
isPlainRecord,
dataView: dataViewMock,
dataView,
stateContainer,
onFieldEdited: jest.fn(),
columns: [],
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
onAddFilter: jest.fn(),
container: null,
panelsToggle: (
<PanelsToggle
stateContainer={stateContainer}
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: () => {},
})
}
isChartAvailable={undefined}
renderedFor="root"
/>
),
};
stateContainer.searchSessionManager = createSearchSessionMock(session).searchSessionManager;
@ -154,11 +173,19 @@ describe('Discover histogram layout component', () => {
it('should not render null if there is a search session', async () => {
const { component } = await mountComponent();
expect(component.isEmptyRender()).toBe(false);
});
}, 10000);
it('should not render null if there is no search session, but isPlainRecord is true', async () => {
const { component } = await mountComponent({ isPlainRecord: true });
expect(component.isEmptyRender()).toBe(false);
});
it('should render PanelsToggle', async () => {
const { component } = await mountComponent();
expect(component.find(PanelsToggle).first().prop('isChartAvailable')).toBe(undefined);
expect(component.find(PanelsToggle).first().prop('renderedFor')).toBe('histogram');
expect(component.find(PanelsToggle).last().prop('isChartAvailable')).toBe(true);
expect(component.find(PanelsToggle).last().prop('renderedFor')).toBe('tabs');
});
});
});

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React from 'react';
import React, { useCallback } from 'react';
import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public';
import { css } from '@emotion/react';
import useObservable from 'react-use/lib/useObservable';
@ -27,6 +27,7 @@ export const DiscoverHistogramLayout = ({
dataView,
stateContainer,
container,
panelsToggle,
...mainContentProps
}: DiscoverHistogramLayoutProps) => {
const { dataState } = stateContainer;
@ -39,6 +40,14 @@ export const DiscoverHistogramLayout = ({
isPlainRecord,
});
const renderCustomChartToggleActions = useCallback(
() =>
React.isValidElement(panelsToggle)
? React.cloneElement(panelsToggle, { renderedFor: 'histogram' })
: panelsToggle,
[panelsToggle]
);
// Initialized when the first search has been requested or
// when in text-based mode since search sessions are not supported
if (!searchSessionId && !isPlainRecord) {
@ -52,15 +61,14 @@ export const DiscoverHistogramLayout = ({
requestAdapter={dataState.inspectorAdapters.requests}
container={container}
css={histogramLayoutCss}
renderCustomChartToggleActions={renderCustomChartToggleActions}
>
<DiscoverMainContent
{...mainContentProps}
stateContainer={stateContainer}
dataView={dataView}
isPlainRecord={isPlainRecord}
// The documents grid doesn't rerender when the chart visibility changes
// which causes it to render blank space, so we need to force a rerender
key={`docKey${hideChart}`}
panelsToggle={panelsToggle}
/>
</UnifiedHistogramContainer>
);

View file

@ -58,6 +58,10 @@ discover-app {
flex-grow: 0;
}
.dscPageContent__panelsToggleWhenNoResults {
padding: $euiSizeS;
}
.dscTable {
// needs for scroll container of lagacy table
min-height: 0;

View file

@ -39,15 +39,13 @@ import { getSessionServiceMock } from '@kbn/data-plugin/public/search/session/mo
import { DiscoverMainProvider } from '../../services/discover_state_provider';
import { act } from 'react-dom/test-utils';
import { ErrorCallout } from '../../../../components/common/error_callout';
import * as localStorageModule from 'react-use/lib/useLocalStorage';
import { PanelsToggle } from '../../../../components/panels_toggle';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
useResizeObserver: jest.fn(() => ({ width: 1000, height: 1000 })),
}));
jest.spyOn(localStorageModule, 'default');
setHeaderActionMenuMounter(jest.fn());
async function mountComponent(
@ -62,7 +60,7 @@ async function mountComponent(
foundDocuments: true,
}) as DataMain$
) {
const searchSourceMock = createSearchSourceMock({});
const searchSourceMock = createSearchSourceMock({ index: dataView });
const services = createDiscoverServicesMock();
const time = { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
services.data.query.timefilter.timefilter.getTime = () => time;
@ -78,9 +76,10 @@ async function mountComponent(
(searchSourceInstanceMock.fetch$ as jest.Mock).mockImplementation(
jest.fn().mockReturnValue(of({ rawResponse: { hits: { total: 2 } } }))
);
(localStorageModule.default as jest.Mock).mockImplementation(
jest.fn(() => [prevSidebarClosed, jest.fn()])
);
if (typeof prevSidebarClosed === 'boolean') {
localStorage.setItem('discover:sidebarClosed', String(prevSidebarClosed));
}
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
@ -110,7 +109,7 @@ async function mountComponent(
session.getSession$.mockReturnValue(new BehaviorSubject('123'));
stateContainer.appState.update({ interval: 'auto', query });
stateContainer.appState.update({ index: dataView.id, interval: 'auto', query });
stateContainer.internalState.transitions.setDataView(dataView);
const props = {
@ -150,17 +149,15 @@ describe('Discover component', () => {
test('selected data view without time field displays no chart toggle', async () => {
const container = document.createElement('div');
await mountComponent(dataViewMock, undefined, { attachTo: container });
expect(
container.querySelector('[data-test-subj="unifiedHistogramChartOptionsToggle"]')
).toBeNull();
expect(container.querySelector('[data-test-subj="dscHideHistogramButton"]')).toBeNull();
expect(container.querySelector('[data-test-subj="dscShowHistogramButton"]')).toBeNull();
}, 10000);
test('selected data view with time field displays chart toggle', async () => {
const container = document.createElement('div');
await mountComponent(dataViewWithTimefieldMock, undefined, { attachTo: container });
expect(
container.querySelector('[data-test-subj="unifiedHistogramChartOptionsToggle"]')
).not.toBeNull();
expect(container.querySelector('[data-test-subj="dscHideHistogramButton"]')).not.toBeNull();
expect(container.querySelector('[data-test-subj="dscShowHistogramButton"]')).toBeNull();
}, 10000);
describe('sidebar', () => {
@ -195,5 +192,7 @@ describe('Discover component', () => {
}) as DataMain$
);
expect(component.find(ErrorCallout)).toHaveLength(1);
expect(component.find(PanelsToggle).prop('isChartAvailable')).toBe(false);
expect(component.find(PanelsToggle).prop('renderedFor')).toBe('prompt');
}, 10000);
});

View file

@ -6,17 +6,8 @@
* Side Public License, v 1.
*/
import './discover_layout.scss';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiHideFor,
EuiPage,
EuiPageBody,
EuiPanel,
useEuiBackgroundColor,
useEuiTheme,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useRef, useState, ReactElement } from 'react';
import { EuiPage, EuiPageBody, EuiPanel, useEuiBackgroundColor } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
@ -31,7 +22,7 @@ import {
} from '@kbn/discover-utils';
import { popularizeField, useColumns } from '@kbn/unified-data-table';
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list';
import { BehaviorSubject } from 'rxjs';
import { useSavedSearchInitial } from '../../services/discover_state_provider';
import { DiscoverStateContainer } from '../../services/discover_state';
import { VIEW_MODE } from '../../../../../common/constants';
@ -45,7 +36,7 @@ import { DiscoverTopNav } from '../top_nav/discover_topnav';
import { getResultState } from '../../utils/get_result_state';
import { DiscoverUninitialized } from '../uninitialized/uninitialized';
import { DataMainMsg, RecordRawType } from '../../services/discover_data_state_container';
import { FetchStatus } from '../../../types';
import { FetchStatus, SidebarToggleState } from '../../../types';
import { useDataState } from '../../hooks/use_data_state';
import { getRawRecordType } from '../../utils/get_raw_record_type';
import { SavedSearchURLConflictCallout } from '../../../../components/saved_search_url_conflict_callout/saved_search_url_conflict_callout';
@ -54,6 +45,7 @@ import { ErrorCallout } from '../../../../components/common/error_callout';
import { addLog } from '../../../../utils/add_log';
import { DiscoverResizableLayout } from './discover_resizable_layout';
import { ESQLTechPreviewCallout } from './esql_tech_preview_callout';
import { PanelsToggle, PanelsToggleProps } from '../../../../components/panels_toggle';
const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
const TopNavMemoized = React.memo(DiscoverTopNav);
@ -75,7 +67,6 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
docLinks,
serverless,
} = useDiscoverServices();
const { euiTheme } = useEuiTheme();
const pageBackgroundColor = useEuiBackgroundColor('plain');
const globalQueryState = data.query.getState();
const { main$ } = stateContainer.dataState.data$;
@ -194,6 +185,21 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
return () => onAddColumn(draggingFieldName);
}, [onAddColumn, draggingFieldName, currentColumns]);
const [sidebarToggleState$] = useState<BehaviorSubject<SidebarToggleState>>(
() => new BehaviorSubject<SidebarToggleState>({ isCollapsed: false, toggle: () => {} })
);
const panelsToggle: ReactElement<PanelsToggleProps> = useMemo(() => {
return (
<PanelsToggle
stateContainer={stateContainer}
sidebarToggleState$={sidebarToggleState$}
renderedFor="root"
isChartAvailable={undefined}
/>
);
}, [stateContainer, sidebarToggleState$]);
const mainDisplay = useMemo(() => {
if (resultState === 'uninitialized') {
addLog('[DiscoverLayout] uninitialized triggers data fetching');
@ -214,6 +220,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
onFieldEdited={onFieldEdited}
container={mainContainer}
onDropFieldToTable={onDropFieldToTable}
panelsToggle={panelsToggle}
/>
{resultState === 'loading' && <LoadingSpinner />}
</>
@ -230,11 +237,9 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
resultState,
stateContainer,
viewMode,
panelsToggle,
]);
const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] =
useState<UnifiedFieldListSidebarContainerApi | null>(null);
return (
<EuiPage
className={classNames('dscPage', { 'dscPage--serverless': serverless })}
@ -282,64 +287,56 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
/>
<DiscoverResizableLayout
container={sidebarContainer}
unifiedFieldListSidebarContainerApi={unifiedFieldListSidebarContainerApi}
sidebarToggleState$={sidebarToggleState$}
sidebarPanel={
<EuiFlexGroup
gutterSize="none"
css={css`
height: 100%;
`}
>
<EuiFlexItem>
<SidebarMemoized
documents$={stateContainer.dataState.data$.documents$}
onAddField={onAddColumn}
columns={currentColumns}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
onRemoveField={onRemoveColumn}
onChangeDataView={stateContainer.actions.onChangeDataView}
selectedDataView={dataView}
trackUiMetric={trackUiMetric}
onFieldEdited={onFieldEdited}
onDataViewCreated={stateContainer.actions.onDataViewCreated}
availableFields$={stateContainer.dataState.data$.availableFields$}
unifiedFieldListSidebarContainerApi={unifiedFieldListSidebarContainerApi}
setUnifiedFieldListSidebarContainerApi={setUnifiedFieldListSidebarContainerApi}
/>
</EuiFlexItem>
<EuiHideFor sizes={['xs', 's']}>
<EuiFlexItem
grow={false}
css={css`
border-right: ${euiTheme.border.thin};
`}
/>
</EuiHideFor>
</EuiFlexGroup>
<SidebarMemoized
documents$={stateContainer.dataState.data$.documents$}
onAddField={onAddColumn}
columns={currentColumns}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
onRemoveField={onRemoveColumn}
onChangeDataView={stateContainer.actions.onChangeDataView}
selectedDataView={dataView}
trackUiMetric={trackUiMetric}
onFieldEdited={onFieldEdited}
onDataViewCreated={stateContainer.actions.onDataViewCreated}
availableFields$={stateContainer.dataState.data$.availableFields$}
sidebarToggleState$={sidebarToggleState$}
/>
}
mainPanel={
<div className="dscPageContent__wrapper">
{resultState === 'none' ? (
dataState.error ? (
<ErrorCallout
title={i18n.translate(
'discover.noResults.searchExamples.noResultsErrorTitle',
{
defaultMessage: 'Unable to retrieve search results',
}
)}
error={dataState.error}
/>
) : (
<DiscoverNoResults
stateContainer={stateContainer}
isTimeBased={isTimeBased}
query={globalQueryState.query}
filters={globalQueryState.filters}
dataView={dataView}
onDisableFilters={onDisableFilters}
/>
)
<>
{React.isValidElement(panelsToggle) ? (
<div className="dscPageContent__panelsToggleWhenNoResults">
{React.cloneElement(panelsToggle, {
renderedFor: 'prompt',
isChartAvailable: false,
})}
</div>
) : null}
{dataState.error ? (
<ErrorCallout
title={i18n.translate(
'discover.noResults.searchExamples.noResultsErrorTitle',
{
defaultMessage: 'Unable to retrieve search results',
}
)}
error={dataState.error}
/>
) : (
<DiscoverNoResults
stateContainer={stateContainer}
isTimeBased={isTimeBased}
query={globalQueryState.query}
filters={globalQueryState.filters}
dataView={dataView}
onDisableFilters={onDisableFilters}
/>
)}
</>
) : (
<EuiPanel
role="main"

View file

@ -8,8 +8,10 @@
import React from 'react';
import { BehaviorSubject, of } from 'rxjs';
import { EuiHorizontalRule } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { DataView } from '@kbn/data-plugin/common';
import { dataViewMock, esHitsMock } from '@kbn/discover-utils/src/__mocks__';
import {
AvailableFields$,
@ -19,7 +21,7 @@ import {
RecordRawType,
} from '../../services/discover_data_state_container';
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { FetchStatus } from '../../../types';
import { FetchStatus, SidebarToggleState } from '../../../types';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { buildDataTableRecord } from '@kbn/discover-utils';
@ -31,16 +33,19 @@ import { DiscoverDocuments } from './discover_documents';
import { FieldStatisticsTab } from '../field_stats_table';
import { DiscoverMainProvider } from '../../services/discover_state_provider';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { PanelsToggle } from '../../../../components/panels_toggle';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
const mountComponent = async ({
hideChart = false,
isPlainRecord = false,
isChartAvailable,
viewMode = VIEW_MODE.DOCUMENT_LEVEL,
storage,
}: {
hideChart?: boolean;
isPlainRecord?: boolean;
isChartAvailable?: boolean;
viewMode?: VIEW_MODE;
storage?: Storage;
savedSearch?: SavedSearch;
@ -80,15 +85,19 @@ const mountComponent = async ({
result: Number(esHitsMock.length),
}) as DataTotalHits$;
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
const savedSearchData$ = {
main$,
documents$,
totalHits$,
availableFields$,
};
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.dataState.data$ = savedSearchData$;
const dataView = stateContainer.savedSearchState
.getState()
.searchSource.getField('index') as DataView;
stateContainer.appState.update({
index: dataView?.id!,
interval: 'auto',
hideChart,
columns: [],
@ -96,12 +105,26 @@ const mountComponent = async ({
const props: DiscoverMainContentProps = {
isPlainRecord,
dataView: dataViewMock,
dataView,
stateContainer,
onFieldEdited: jest.fn(),
columns: [],
viewMode,
onAddFilter: jest.fn(),
isChartAvailable,
panelsToggle: (
<PanelsToggle
stateContainer={stateContainer}
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: () => {},
})
}
isChartAvailable={undefined}
renderedFor="root"
/>
),
};
const component = mountWithIntl(
@ -128,15 +151,36 @@ describe('Discover main content component', () => {
expect(component.find(DiscoverDocuments).prop('viewModeToggle')).toBeDefined();
});
it('should not show DocumentViewModeToggle when isPlainRecord is true', async () => {
it('should include DocumentViewModeToggle when isPlainRecord is true', async () => {
const component = await mountComponent({ isPlainRecord: true });
expect(component.find(DiscoverDocuments).prop('viewModeToggle')).toBeUndefined();
expect(component.find(DiscoverDocuments).prop('viewModeToggle')).toBeDefined();
});
it('should show DocumentViewModeToggle for Field Statistics', async () => {
const component = await mountComponent({ viewMode: VIEW_MODE.AGGREGATED_LEVEL });
expect(component.find(DocumentViewModeToggle).exists()).toBe(true);
});
it('should include PanelsToggle when chart is available', async () => {
const component = await mountComponent({ isChartAvailable: true });
expect(component.find(PanelsToggle).prop('isChartAvailable')).toBe(true);
expect(component.find(PanelsToggle).prop('renderedFor')).toBe('tabs');
expect(component.find(EuiHorizontalRule).exists()).toBe(true);
});
it('should include PanelsToggle when chart is available and hidden', async () => {
const component = await mountComponent({ isChartAvailable: true, hideChart: true });
expect(component.find(PanelsToggle).prop('isChartAvailable')).toBe(true);
expect(component.find(PanelsToggle).prop('renderedFor')).toBe('tabs');
expect(component.find(EuiHorizontalRule).exists()).toBe(false);
});
it('should include PanelsToggle when chart is not available', async () => {
const component = await mountComponent({ isChartAvailable: false });
expect(component.find(PanelsToggle).prop('isChartAvailable')).toBe(false);
expect(component.find(PanelsToggle).prop('renderedFor')).toBe('tabs');
expect(component.find(EuiHorizontalRule).exists()).toBe(false);
});
});
describe('Document view', () => {

View file

@ -8,7 +8,7 @@
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
import { DragDrop, type DropType, DropOverlayWrapper } from '@kbn/dom-drag-drop';
import React, { useCallback, useMemo } from 'react';
import React, { ReactElement, useCallback, useMemo } from 'react';
import { DataView } from '@kbn/data-views-plugin/common';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
@ -21,6 +21,7 @@ import { FieldStatisticsTab } from '../field_stats_table';
import { DiscoverDocuments } from './discover_documents';
import { DOCUMENTS_VIEW_CLICK, FIELD_STATISTICS_VIEW_CLICK } from '../field_stats_table/constants';
import { useAppStateSelector } from '../../services/discover_app_state_container';
import type { PanelsToggleProps } from '../../../../components/panels_toggle';
const DROP_PROPS = {
value: {
@ -44,6 +45,8 @@ export interface DiscoverMainContentProps {
onFieldEdited: () => Promise<void>;
onDropFieldToTable?: () => void;
columns: string[];
panelsToggle: ReactElement<PanelsToggleProps>;
isChartAvailable?: boolean; // it will be injected by UnifiedHistogram
}
export const DiscoverMainContent = ({
@ -55,6 +58,8 @@ export const DiscoverMainContent = ({
columns,
stateContainer,
onDropFieldToTable,
panelsToggle,
isChartAvailable,
}: DiscoverMainContentProps) => {
const { trackUiMetric } = useDiscoverServices();
@ -76,10 +81,27 @@ export const DiscoverMainContent = ({
const isDropAllowed = Boolean(onDropFieldToTable);
const viewModeToggle = useMemo(() => {
return !isPlainRecord ? (
<DocumentViewModeToggle viewMode={viewMode} setDiscoverViewMode={setDiscoverViewMode} />
) : undefined;
}, [viewMode, setDiscoverViewMode, isPlainRecord]);
return (
<DocumentViewModeToggle
viewMode={viewMode}
isTextBasedQuery={isPlainRecord}
stateContainer={stateContainer}
setDiscoverViewMode={setDiscoverViewMode}
prepend={
React.isValidElement(panelsToggle)
? React.cloneElement(panelsToggle, { renderedFor: 'tabs', isChartAvailable })
: undefined
}
/>
);
}, [
viewMode,
setDiscoverViewMode,
isPlainRecord,
stateContainer,
panelsToggle,
isChartAvailable,
]);
const showChart = useAppStateSelector((state) => !state.hideChart);
@ -99,7 +121,7 @@ export const DiscoverMainContent = ({
responsive={false}
data-test-subj="dscMainContent"
>
{showChart && <EuiHorizontalRule margin="none" />}
{showChart && isChartAvailable && <EuiHorizontalRule margin="none" />}
{viewMode === VIEW_MODE.DOCUMENT_LEVEL ? (
<DiscoverDocuments
viewModeToggle={viewModeToggle}

View file

@ -12,12 +12,12 @@ import {
ResizableLayoutMode,
} from '@kbn/resizable-layout';
import { findTestSubject } from '@kbn/test-jest-helpers';
import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list';
import { mount } from 'enzyme';
import { isEqual as mockIsEqual } from 'lodash';
import React from 'react';
import { of } from 'rxjs';
import { DiscoverResizableLayout, SIDEBAR_WIDTH_KEY } from './discover_resizable_layout';
import { BehaviorSubject } from 'rxjs';
import { SidebarToggleState } from '../../../types';
const mockSidebarKey = SIDEBAR_WIDTH_KEY;
let mockSidebarWidth: number | undefined;
@ -56,7 +56,12 @@ describe('DiscoverResizableLayout', () => {
const wrapper = mount(
<DiscoverResizableLayout
container={null}
unifiedFieldListSidebarContainerApi={null}
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: () => {},
})
}
sidebarPanel={<div data-test-subj="sidebarPanel" />}
mainPanel={<div data-test-subj="mainPanel" />}
/>
@ -69,7 +74,12 @@ describe('DiscoverResizableLayout', () => {
const wrapper = mount(
<DiscoverResizableLayout
container={null}
unifiedFieldListSidebarContainerApi={null}
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: () => {},
})
}
sidebarPanel={<div data-test-subj="sidebarPanel" />}
mainPanel={<div data-test-subj="mainPanel" />}
/>
@ -82,7 +92,12 @@ describe('DiscoverResizableLayout', () => {
const wrapper = mount(
<DiscoverResizableLayout
container={null}
unifiedFieldListSidebarContainerApi={null}
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: () => {},
})
}
sidebarPanel={<div data-test-subj="sidebarPanel" />}
mainPanel={<div data-test-subj="mainPanel" />}
/>
@ -95,8 +110,11 @@ describe('DiscoverResizableLayout', () => {
const wrapper = mount(
<DiscoverResizableLayout
container={null}
unifiedFieldListSidebarContainerApi={
{ isSidebarCollapsed$: of(false) } as UnifiedFieldListSidebarContainerApi
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: () => {},
})
}
sidebarPanel={<div data-test-subj="sidebarPanel" />}
mainPanel={<div data-test-subj="mainPanel" />}
@ -110,8 +128,11 @@ describe('DiscoverResizableLayout', () => {
const wrapper = mount(
<DiscoverResizableLayout
container={null}
unifiedFieldListSidebarContainerApi={
{ isSidebarCollapsed$: of(false) } as UnifiedFieldListSidebarContainerApi
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: () => {},
})
}
sidebarPanel={<div data-test-subj="sidebarPanel" />}
mainPanel={<div data-test-subj="mainPanel" />}
@ -125,8 +146,11 @@ describe('DiscoverResizableLayout', () => {
const wrapper = mount(
<DiscoverResizableLayout
container={null}
unifiedFieldListSidebarContainerApi={
{ isSidebarCollapsed$: of(true) } as UnifiedFieldListSidebarContainerApi
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: () => {},
})
}
sidebarPanel={<div data-test-subj="sidebarPanel" />}
mainPanel={<div data-test-subj="mainPanel" />}
@ -140,8 +164,11 @@ describe('DiscoverResizableLayout', () => {
const wrapper = mount(
<DiscoverResizableLayout
container={null}
unifiedFieldListSidebarContainerApi={
{ isSidebarCollapsed$: of(false) } as UnifiedFieldListSidebarContainerApi
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: () => {},
})
}
sidebarPanel={<div data-test-subj="sidebarPanel" />}
mainPanel={<div data-test-subj="mainPanel" />}
@ -157,8 +184,11 @@ describe('DiscoverResizableLayout', () => {
const wrapper = mount(
<DiscoverResizableLayout
container={null}
unifiedFieldListSidebarContainerApi={
{ isSidebarCollapsed$: of(false) } as UnifiedFieldListSidebarContainerApi
sidebarToggleState$={
new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: () => {},
})
}
sidebarPanel={<div data-test-subj="sidebarPanel" />}
mainPanel={<div data-test-subj="mainPanel" />}

View file

@ -12,23 +12,23 @@ import {
ResizableLayoutDirection,
ResizableLayoutMode,
} from '@kbn/resizable-layout';
import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list';
import React, { ReactNode, useState } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import useObservable from 'react-use/lib/useObservable';
import { of } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import { SidebarToggleState } from '../../../types';
export const SIDEBAR_WIDTH_KEY = 'discover:sidebarWidth';
export const DiscoverResizableLayout = ({
container,
unifiedFieldListSidebarContainerApi,
sidebarToggleState$,
sidebarPanel,
mainPanel,
}: {
container: HTMLElement | null;
unifiedFieldListSidebarContainerApi: UnifiedFieldListSidebarContainerApi | null;
sidebarToggleState$: BehaviorSubject<SidebarToggleState>;
sidebarPanel: ReactNode;
mainPanel: ReactNode;
}) => {
@ -45,10 +45,9 @@ export const DiscoverResizableLayout = ({
const minMainPanelWidth = euiTheme.base * 30;
const [sidebarWidth, setSidebarWidth] = useLocalStorage(SIDEBAR_WIDTH_KEY, defaultSidebarWidth);
const isSidebarCollapsed = useObservable(
unifiedFieldListSidebarContainerApi?.isSidebarCollapsed$ ?? of(true),
true
);
const sidebarToggleState = useObservable(sidebarToggleState$);
const isSidebarCollapsed = sidebarToggleState?.isCollapsed ?? false;
const isMobile = useIsWithinBreakpoints(['xs', 's']);
const layoutMode =

View file

@ -261,20 +261,14 @@ describe('useDiscoverHistogram', () => {
hook.result.current.ref(api);
});
stateContainer.appState.update({ hideChart: true, interval: '1m', breakdownField: 'test' });
expect(api.setTotalHits).toHaveBeenCalled();
expect(api.setTotalHits).not.toHaveBeenCalled();
expect(api.setChartHidden).toHaveBeenCalled();
expect(api.setTimeInterval).toHaveBeenCalled();
expect(api.setBreakdownField).toHaveBeenCalled();
expect(Object.keys(params ?? {})).toEqual([
'totalHitsStatus',
'totalHitsResult',
'breakdownField',
'timeInterval',
'chartHidden',
]);
expect(Object.keys(params ?? {})).toEqual(['breakdownField', 'timeInterval', 'chartHidden']);
});
it('should exclude totalHitsStatus and totalHitsResult from Unified Histogram state updates after the first load', async () => {
it('should exclude totalHitsStatus and totalHitsResult from Unified Histogram state updates', async () => {
const stateContainer = getStateContainer();
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const containerState = stateContainer.appState.getState();
@ -290,20 +284,13 @@ describe('useDiscoverHistogram', () => {
api.setChartHidden = jest.fn((chartHidden) => {
params = { ...params, chartHidden };
});
api.setTotalHits = jest.fn((p) => {
params = { ...params, ...p };
});
const subject$ = new BehaviorSubject(state);
api.state$ = subject$;
act(() => {
hook.result.current.ref(api);
});
stateContainer.appState.update({ hideChart: true });
expect(Object.keys(params ?? {})).toEqual([
'totalHitsStatus',
'totalHitsResult',
'chartHidden',
]);
expect(Object.keys(params ?? {})).toEqual(['chartHidden']);
params = {};
stateContainer.appState.update({ hideChart: false });
act(() => {
@ -434,14 +421,14 @@ describe('useDiscoverHistogram', () => {
act(() => {
hook.result.current.ref(api);
});
expect(api.refetch).not.toHaveBeenCalled();
expect(api.refetch).toHaveBeenCalled();
act(() => {
savedSearchFetch$.next({
options: { reset: false, fetchMore: false },
searchSessionId: '1234',
});
});
expect(api.refetch).toHaveBeenCalled();
expect(api.refetch).toHaveBeenCalledTimes(2);
});
it('should skip the next refetch when hideChart changes from true to false', async () => {
@ -459,6 +446,7 @@ describe('useDiscoverHistogram', () => {
act(() => {
hook.result.current.ref(api);
});
expect(api.refetch).toHaveBeenCalled();
act(() => {
hook.rerender({ ...initialProps, hideChart: true });
});
@ -471,7 +459,7 @@ describe('useDiscoverHistogram', () => {
searchSessionId: '1234',
});
});
expect(api.refetch).not.toHaveBeenCalled();
expect(api.refetch).toHaveBeenCalledTimes(1);
});
it('should skip the next refetch when fetching more', async () => {
@ -489,13 +477,14 @@ describe('useDiscoverHistogram', () => {
act(() => {
hook.result.current.ref(api);
});
expect(api.refetch).toHaveBeenCalledTimes(1);
act(() => {
savedSearchFetch$.next({
options: { reset: false, fetchMore: true },
searchSessionId: '1234',
});
});
expect(api.refetch).not.toHaveBeenCalled();
expect(api.refetch).toHaveBeenCalledTimes(1);
act(() => {
savedSearchFetch$.next({
@ -503,7 +492,7 @@ describe('useDiscoverHistogram', () => {
searchSessionId: '1234',
});
});
expect(api.refetch).toHaveBeenCalled();
expect(api.refetch).toHaveBeenCalledTimes(2);
});
});

View file

@ -30,7 +30,6 @@ import { useDiscoverCustomization } from '../../../../customizations';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { getUiActions } from '../../../../kibana_services';
import { FetchStatus } from '../../../types';
import { useDataState } from '../../hooks/use_data_state';
import type { InspectorAdapters } from '../../hooks/use_inspector';
import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_messages';
import type { DiscoverStateContainer } from '../../services/discover_state';
@ -68,9 +67,6 @@ export const useDiscoverHistogram = ({
breakdownField,
} = stateContainer.appState.getState();
const { fetchStatus: totalHitsStatus, result: totalHitsResult } =
savedSearchData$.totalHits$.getValue();
return {
localStorageKeyPrefix: 'discover',
disableAutoFetching: true,
@ -78,11 +74,11 @@ export const useDiscoverHistogram = ({
chartHidden,
timeInterval,
breakdownField,
totalHitsStatus: totalHitsStatus.toString() as UnifiedHistogramFetchStatus,
totalHitsResult,
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
totalHitsResult: undefined,
},
};
}, [savedSearchData$.totalHits$, stateContainer.appState]);
}, [stateContainer.appState]);
/**
* Sync Unified Histogram state with Discover state
@ -115,28 +111,6 @@ export const useDiscoverHistogram = ({
};
}, [inspectorAdapters, stateContainer.appState, unifiedHistogram?.state$]);
/**
* Override Unified Histgoram total hits with Discover partial results
*/
const firstLoadComplete = useRef(false);
const { fetchStatus: totalHitsStatus, result: totalHitsResult } = useDataState(
savedSearchData$.totalHits$
);
useEffect(() => {
// We only want to show the partial results on the first load,
// or there will be a flickering effect as the loading spinner
// is quickly shown and hidden again on fetches
if (!firstLoadComplete.current) {
unifiedHistogram?.setTotalHits({
totalHitsStatus: totalHitsStatus.toString() as UnifiedHistogramFetchStatus,
totalHitsResult,
});
}
}, [totalHitsResult, totalHitsStatus, unifiedHistogram]);
/**
* Sync URL query params with Unified Histogram
*/
@ -181,7 +155,17 @@ export const useDiscoverHistogram = ({
return;
}
const { recordRawType } = savedSearchData$.totalHits$.getValue();
const { recordRawType, result: totalHitsResult } = savedSearchData$.totalHits$.getValue();
if (
(status === UnifiedHistogramFetchStatus.loading ||
status === UnifiedHistogramFetchStatus.uninitialized) &&
totalHitsResult &&
typeof result !== 'number'
) {
// ignore the histogram initial loading state if discover state already has a total hits value
return;
}
// Sync the totalHits$ observable with the unified histogram state
savedSearchData$.totalHits$.next({
@ -196,10 +180,6 @@ export const useDiscoverHistogram = ({
// Check the hits count to set a partial or no results state
checkHitCount(savedSearchData$.main$, result);
// Indicate the first load has completed so we don't show
// partial results on subsequent fetches
firstLoadComplete.current = true;
}
);
@ -317,6 +297,11 @@ export const useDiscoverHistogram = ({
skipRefetch.current = false;
});
// triggering the initial request for total hits hook
if (!isPlainRecord && !skipRefetch.current) {
unifiedHistogram.refetch();
}
return () => {
subscription.unsubscribe();
};
@ -326,14 +311,24 @@ export const useDiscoverHistogram = ({
const histogramCustomization = useDiscoverCustomization('unified_histogram');
const servicesMemoized = useMemo(() => ({ ...services, uiActions: getUiActions() }), [services]);
const filtersMemoized = useMemo(
() => [...(filters ?? []), ...customFilters],
[filters, customFilters]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const timeRangeMemoized = useMemo(() => timeRange, [timeRange?.from, timeRange?.to]);
return {
ref,
getCreationOptions,
services: { ...services, uiActions: getUiActions() },
services: servicesMemoized,
dataView: isPlainRecord ? textBasedDataView : dataView,
query: isPlainRecord ? textBasedQuery : query,
filters: [...(filters ?? []), ...customFilters],
timeRange,
filters: filtersMemoized,
timeRange: timeRangeMemoized,
relativeTimeRange,
columns,
onFilter: histogramCustomization?.onFilter,

View file

@ -13,13 +13,13 @@ import { EuiProgress } from '@elastic/eui';
import { getDataTableRecords, realHits } from '../../../../__fixtures__/real_hits';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import React, { useState } from 'react';
import React from 'react';
import {
DiscoverSidebarResponsive,
DiscoverSidebarResponsiveProps,
} from './discover_sidebar_responsive';
import { DiscoverServices } from '../../../../build_services';
import { FetchStatus } from '../../../types';
import { FetchStatus, SidebarToggleState } from '../../../types';
import {
AvailableFields$,
DataDocuments$,
@ -37,7 +37,6 @@ import { buildDataTableRecord } from '@kbn/discover-utils';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
import type { SearchBarCustomization } from '../../../../customizations';
import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list';
const mockSearchBarCustomization: SearchBarCustomization = {
id: 'search_bar',
@ -169,8 +168,10 @@ function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarRe
trackUiMetric: jest.fn(),
onFieldEdited: jest.fn(),
onDataViewCreated: jest.fn(),
unifiedFieldListSidebarContainerApi: null,
setUnifiedFieldListSidebarContainerApi: jest.fn(),
sidebarToggleState$: new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: () => {},
}),
};
}
@ -202,21 +203,10 @@ async function mountComponent(
mockedServices.data.query.getState = jest.fn().mockImplementation(() => appState.getState());
await act(async () => {
const SidebarWrapper = () => {
const [api, setApi] = useState<UnifiedFieldListSidebarContainerApi | null>(null);
return (
<DiscoverSidebarResponsive
{...props}
unifiedFieldListSidebarContainerApi={api}
setUnifiedFieldListSidebarContainerApi={setApi}
/>
);
};
comp = mountWithIntl(
<KibanaContextProvider services={mockedServices}>
<DiscoverAppStateProvider value={appState}>
<SidebarWrapper />
<DiscoverSidebarResponsive {...props} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
);

View file

@ -6,9 +6,13 @@
* Side Public License, v 1.
*/
import React, { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { UiCounterMetricType } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { EuiFlexGroup, EuiFlexItem, EuiHideFor, useEuiTheme } from '@elastic/eui';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject, of } from 'rxjs';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
import {
@ -25,7 +29,7 @@ import {
RecordRawType,
} from '../../services/discover_data_state_container';
import { calcFieldCounts } from '../../utils/calc_field_counts';
import { FetchStatus } from '../../../types';
import { FetchStatus, SidebarToggleState } from '../../../types';
import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour';
import { getUiActions } from '../../../../kibana_services';
import {
@ -134,8 +138,7 @@ export interface DiscoverSidebarResponsiveProps {
*/
fieldListVariant?: UnifiedFieldListSidebarContainerProps['variant'];
unifiedFieldListSidebarContainerApi: UnifiedFieldListSidebarContainerApi | null;
setUnifiedFieldListSidebarContainerApi: (api: UnifiedFieldListSidebarContainerApi) => void;
sidebarToggleState$: BehaviorSubject<SidebarToggleState>;
}
/**
@ -144,6 +147,9 @@ export interface DiscoverSidebarResponsiveProps {
* Mobile: Data view selector is visible and a button to trigger a flyout with all elements
*/
export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) {
const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] =
useState<UnifiedFieldListSidebarContainerApi | null>(null);
const { euiTheme } = useEuiTheme();
const services = useDiscoverServices();
const {
fieldListVariant,
@ -156,8 +162,7 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
onChangeDataView,
onAddField,
onRemoveField,
unifiedFieldListSidebarContainerApi,
setUnifiedFieldListSidebarContainerApi,
sidebarToggleState$,
} = props;
const [sidebarState, dispatchSidebarStateAction] = useReducer(
discoverSidebarReducer,
@ -373,27 +378,55 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
[onRemoveField]
);
if (!selectedDataView) {
return null;
}
const isSidebarCollapsed = useObservable(
unifiedFieldListSidebarContainerApi?.sidebarVisibility.isCollapsed$ ?? of(false),
false
);
useEffect(() => {
sidebarToggleState$.next({
isCollapsed: isSidebarCollapsed,
toggle: unifiedFieldListSidebarContainerApi?.sidebarVisibility.toggle,
});
}, [isSidebarCollapsed, unifiedFieldListSidebarContainerApi, sidebarToggleState$]);
return (
<UnifiedFieldListSidebarContainer
ref={initializeUnifiedFieldListSidebarContainerApi}
variant={fieldListVariant}
getCreationOptions={getCreationOptions}
services={fieldListSidebarServices}
dataView={selectedDataView}
trackUiMetric={trackUiMetric}
allFields={sidebarState.allFields}
showFieldList={showFieldList}
workspaceSelectedFieldNames={columns}
fullWidth
onAddFieldToWorkspace={onAddFieldToWorkspace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
onAddFilter={onAddFilter}
onFieldEdited={onFieldEdited}
prependInFlyout={prependDataViewPickerForMobile}
/>
<EuiFlexGroup
gutterSize="none"
css={css`
height: 100%;
display: ${isSidebarCollapsed ? 'none' : 'flex'};
`}
>
<EuiFlexItem>
{selectedDataView ? (
<UnifiedFieldListSidebarContainer
ref={initializeUnifiedFieldListSidebarContainerApi}
variant={fieldListVariant}
getCreationOptions={getCreationOptions}
services={fieldListSidebarServices}
dataView={selectedDataView}
trackUiMetric={trackUiMetric}
allFields={sidebarState.allFields}
showFieldList={showFieldList}
workspaceSelectedFieldNames={columns}
fullWidth
onAddFieldToWorkspace={onAddFieldToWorkspace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
onAddFilter={onAddFilter}
onFieldEdited={onFieldEdited}
prependInFlyout={prependDataViewPickerForMobile}
/>
) : null}
</EuiFlexItem>
<EuiHideFor sizes={['xs', 's']}>
<EuiFlexItem
grow={false}
css={css`
border-right: ${euiTheme.border.thin};
`}
/>
</EuiHideFor>
</EuiFlexGroup>
);
}

View file

@ -89,7 +89,7 @@ export function fetchAll(
// Mark all subjects as loading
sendLoadingMsg(dataSubjects.main$, { recordRawType });
sendLoadingMsg(dataSubjects.documents$, { recordRawType, query });
sendLoadingMsg(dataSubjects.totalHits$, { recordRawType });
// histogram will send `loading` for totalHits$
// Start fetching all required requests
const response =
@ -116,9 +116,12 @@ export function fetchAll(
meta: { fetchType },
});
}
const currentTotalHits = dataSubjects.totalHits$.getValue();
// If the total hits (or chart) query is still loading, emit a partial
// hit count that's at least our retrieved document count
if (dataSubjects.totalHits$.getValue().fetchStatus === FetchStatus.LOADING) {
if (currentTotalHits.fetchStatus === FetchStatus.LOADING && !currentTotalHits.result) {
// trigger `partial` only for the first request (if no total hits value yet)
dataSubjects.totalHits$.next({
fetchStatus: FetchStatus.PARTIAL,
result: records.length,

View file

@ -38,3 +38,8 @@ export interface RecordsFetchResponse {
textBasedHeaderWarning?: string;
interceptedWarnings?: SearchResponseWarning[];
}
export interface SidebarToggleState {
isCollapsed: boolean;
toggle: undefined | ((isCollapsed: boolean) => void);
}

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 from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { HitsCounter, HitsCounterMode } from './hits_counter';
import { findTestSubject } from '@elastic/eui/lib/test';
import { EuiLoadingSpinner } from '@elastic/eui';
import { BehaviorSubject } from 'rxjs';
import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock';
import { DataTotalHits$ } from '../../application/main/services/discover_data_state_container';
import { FetchStatus } from '../../application/types';
describe('hits counter', function () {
it('expect to render the number of hits', function () {
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: 1,
}) as DataTotalHits$;
const component1 = mountWithIntl(
<HitsCounter mode={HitsCounterMode.appended} stateContainer={stateContainer} />
);
expect(findTestSubject(component1, 'discoverQueryHits').text()).toBe('1');
expect(findTestSubject(component1, 'discoverQueryTotalHits').text()).toBe('1');
expect(component1.find('[data-test-subj="discoverQueryHits"]').length).toBe(1);
const component2 = mountWithIntl(
<HitsCounter mode={HitsCounterMode.standalone} stateContainer={stateContainer} />
);
expect(findTestSubject(component2, 'discoverQueryHits').text()).toBe('1');
expect(findTestSubject(component2, 'discoverQueryTotalHits').text()).toBe('1 result');
expect(component2.find('[data-test-subj="discoverQueryHits"]').length).toBe(1);
});
it('expect to render 1,899 hits if 1899 hits given', function () {
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: 1899,
}) as DataTotalHits$;
const component1 = mountWithIntl(
<HitsCounter mode={HitsCounterMode.appended} stateContainer={stateContainer} />
);
expect(findTestSubject(component1, 'discoverQueryHits').text()).toBe('1,899');
expect(findTestSubject(component1, 'discoverQueryTotalHits').text()).toBe('1,899');
const component2 = mountWithIntl(
<HitsCounter mode={HitsCounterMode.standalone} stateContainer={stateContainer} />
);
expect(findTestSubject(component2, 'discoverQueryHits').text()).toBe('1,899');
expect(findTestSubject(component2, 'discoverQueryTotalHits').text()).toBe('1,899 results');
});
it('should render a EuiLoadingSpinner when status is partial', () => {
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.PARTIAL,
result: 2,
}) as DataTotalHits$;
const component = mountWithIntl(
<HitsCounter mode={HitsCounterMode.standalone} stateContainer={stateContainer} />
);
expect(component.find(EuiLoadingSpinner).length).toBe(1);
});
it('should render discoverQueryHitsPartial when status is partial', () => {
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.PARTIAL,
result: 2,
}) as DataTotalHits$;
const component = mountWithIntl(
<HitsCounter mode={HitsCounterMode.standalone} stateContainer={stateContainer} />
);
expect(component.find('[data-test-subj="discoverQueryHitsPartial"]').length).toBe(1);
expect(findTestSubject(component, 'discoverQueryTotalHits').text()).toBe('≥2 results');
});
it('should not render if loading', () => {
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.LOADING,
result: undefined,
}) as DataTotalHits$;
const component = mountWithIntl(
<HitsCounter mode={HitsCounterMode.standalone} stateContainer={stateContainer} />
);
expect(component.isEmptyRender()).toBe(true);
});
});

View file

@ -0,0 +1,117 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiLoadingSpinner } from '@elastic/eui';
import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import type { DiscoverStateContainer } from '../../application/main/services/discover_state';
import { FetchStatus } from '../../application/types';
import { useDataState } from '../../application/main/hooks/use_data_state';
export enum HitsCounterMode {
standalone = 'standalone',
appended = 'appended',
}
export interface HitsCounterProps {
mode: HitsCounterMode;
stateContainer: DiscoverStateContainer;
}
export const HitsCounter: React.FC<HitsCounterProps> = ({ mode, stateContainer }) => {
const totalHits$ = stateContainer.dataState.data$.totalHits$;
const totalHitsState = useDataState(totalHits$);
const hitsTotal = totalHitsState.result;
const hitsStatus = totalHitsState.fetchStatus;
if (!hitsTotal && hitsStatus === FetchStatus.LOADING) {
return null;
}
const formattedHits = (
<span
data-test-subj={
hitsStatus === FetchStatus.PARTIAL ? 'discoverQueryHitsPartial' : 'discoverQueryHits'
}
>
<FormattedNumber value={hitsTotal ?? 0} />
</span>
);
const hitsCounterCss = css`
display: inline-flex;
`;
const hitsCounterTextCss = css`
overflow: hidden;
`;
const element = (
<EuiFlexGroup
gutterSize="s"
responsive={false}
justifyContent="center"
alignItems="center"
className="eui-textTruncate eui-textNoWrap"
css={hitsCounterCss}
data-test-subj="discoverQueryTotalHits"
>
<EuiFlexItem grow={false} aria-live="polite" css={hitsCounterTextCss}>
<EuiText className="eui-textTruncate" size="s">
<strong>
{hitsStatus === FetchStatus.PARTIAL &&
(mode === HitsCounterMode.standalone ? (
<FormattedMessage
id="discover.hitsCounter.partialHitsPluralTitle"
defaultMessage="≥{formattedHits} {hits, plural, one {result} other {results}}"
values={{ hits: hitsTotal, formattedHits }}
/>
) : (
<FormattedMessage
id="discover.hitsCounter.partialHits"
defaultMessage="≥{formattedHits}"
values={{ formattedHits }}
/>
))}
{hitsStatus !== FetchStatus.PARTIAL &&
(mode === HitsCounterMode.standalone ? (
<FormattedMessage
id="discover.hitsCounter.hitsPluralTitle"
defaultMessage="{formattedHits} {hits, plural, one {result} other {results}}"
values={{ hits: hitsTotal, formattedHits }}
/>
) : (
formattedHits
))}
</strong>
</EuiText>
</EuiFlexItem>
{hitsStatus === FetchStatus.PARTIAL && (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner
size="m"
aria-label={i18n.translate('discover.hitsCounter.hitCountSpinnerAriaLabel', {
defaultMessage: 'Final hit count still loading',
})}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
return mode === HitsCounterMode.appended ? (
<>
{' ('}
{element}
{')'}
</>
) : (
element
);
};

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export { HitsCounter } from './hits_counter';
export { HitsCounter, HitsCounterMode } from './hits_counter';

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { PanelsToggle, type PanelsToggleProps } from './panels_toggle';

View file

@ -0,0 +1,206 @@
/*
* 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 { findTestSubject } from '@elastic/eui/lib/test';
import { BehaviorSubject } from 'rxjs';
import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock';
import { PanelsToggle, type PanelsToggleProps } from './panels_toggle';
import { DiscoverAppStateProvider } from '../../application/main/services/discover_app_state_container';
import { SidebarToggleState } from '../../application/types';
describe('Panels toggle component', () => {
const mountComponent = ({
sidebarToggleState$,
isChartAvailable,
renderedFor,
hideChart,
}: Omit<PanelsToggleProps, 'stateContainer'> & { hideChart: boolean }) => {
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
const appStateContainer = stateContainer.appState;
appStateContainer.set({
hideChart,
});
return mountWithIntl(
<DiscoverAppStateProvider value={appStateContainer}>
<PanelsToggle
stateContainer={stateContainer}
sidebarToggleState$={sidebarToggleState$}
isChartAvailable={isChartAvailable}
renderedFor={renderedFor}
/>
</DiscoverAppStateProvider>
);
};
describe('inside histogram toolbar', function () {
it('should render correctly when sidebar is visible and histogram is visible', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: false,
isChartAvailable: undefined,
renderedFor: 'histogram',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(true);
});
it('should render correctly when sidebar is collapsed and histogram is visible', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: false,
isChartAvailable: undefined,
renderedFor: 'histogram',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(true);
expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(true);
findTestSubject(component, 'dscShowSidebarButton').simulate('click');
expect(sidebarToggleState$.getValue().toggle).toHaveBeenCalledWith(false);
});
});
describe('inside view mode tabs', function () {
it('should render correctly when sidebar is visible and histogram is visible', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: false,
isChartAvailable: true,
renderedFor: 'tabs',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false);
});
it('should render correctly when sidebar is visible and histogram is visible but chart is not available', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: false,
isChartAvailable: false,
renderedFor: 'tabs',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false);
});
it('should render correctly when sidebar is hidden and histogram is visible', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: false,
isChartAvailable: true,
renderedFor: 'tabs',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false);
});
it('should render correctly when sidebar is hidden and histogram is visible but chart is not available', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: false,
isChartAvailable: false,
renderedFor: 'tabs',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(true);
expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false);
});
it('should render correctly when sidebar is visible and histogram is hidden', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: true,
isChartAvailable: true,
renderedFor: 'tabs',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(true);
});
it('should render correctly when sidebar is visible and histogram is hidden but chart is not available', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: false,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: true,
isChartAvailable: false,
renderedFor: 'tabs',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false);
});
it('should render correctly when sidebar is hidden and histogram is hidden', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: true,
isChartAvailable: true,
renderedFor: 'tabs',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(true);
expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(true);
});
it('should render correctly when sidebar is hidden and histogram is hidden but chart is not available', () => {
const sidebarToggleState$ = new BehaviorSubject<SidebarToggleState>({
isCollapsed: true,
toggle: jest.fn(),
});
const component = mountComponent({
hideChart: true,
isChartAvailable: false,
renderedFor: 'tabs',
sidebarToggleState$,
});
expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(true);
expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false);
expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false);
});
});
});

View file

@ -0,0 +1,101 @@
/*
* 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, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject } from 'rxjs';
import { IconButtonGroup } from '@kbn/shared-ux-button-toolbar';
import { useAppStateSelector } from '../../application/main/services/discover_app_state_container';
import { DiscoverStateContainer } from '../../application/main/services/discover_state';
import { SidebarToggleState } from '../../application/types';
export interface PanelsToggleProps {
stateContainer: DiscoverStateContainer;
sidebarToggleState$: BehaviorSubject<SidebarToggleState>;
renderedFor: 'histogram' | 'prompt' | 'tabs' | 'root';
isChartAvailable: boolean | undefined; // it will be injected in `DiscoverMainContent` when rendering View mode tabs or in `DiscoverLayout` when rendering No results or Error prompt
}
/**
* An element of this component is created in DiscoverLayout
* @param stateContainer
* @param sidebarToggleState$
* @param renderedIn
* @param isChartAvailable
* @constructor
*/
export const PanelsToggle: React.FC<PanelsToggleProps> = ({
stateContainer,
sidebarToggleState$,
renderedFor,
isChartAvailable,
}) => {
const isChartHidden = useAppStateSelector((state) => Boolean(state.hideChart));
const onToggleChart = useCallback(() => {
stateContainer.appState.update({ hideChart: !isChartHidden });
}, [stateContainer, isChartHidden]);
const sidebarToggleState = useObservable(sidebarToggleState$);
const isSidebarCollapsed = sidebarToggleState?.isCollapsed ?? false;
const isInsideHistogram = renderedFor === 'histogram';
const isInsideDiscoverContent = !isInsideHistogram;
const buttons = [
...((isInsideHistogram && isSidebarCollapsed) ||
(isInsideDiscoverContent && isSidebarCollapsed && (isChartHidden || !isChartAvailable))
? [
{
label: i18n.translate('discover.panelsToggle.showSidebarButton', {
defaultMessage: 'Show sidebar',
}),
iconType: 'transitionLeftIn',
'data-test-subj': 'dscShowSidebarButton',
'aria-expanded': !isSidebarCollapsed,
'aria-controls': 'discover-sidebar',
onClick: () => sidebarToggleState?.toggle?.(false),
},
]
: []),
...(isInsideHistogram || (isInsideDiscoverContent && isChartAvailable && isChartHidden)
? [
{
label: isChartHidden
? i18n.translate('discover.panelsToggle.showChartButton', {
defaultMessage: 'Show chart',
})
: i18n.translate('discover.panelsToggle.hideChartButton', {
defaultMessage: 'Hide chart',
}),
iconType: isChartHidden ? 'transitionTopIn' : 'transitionTopOut',
'data-test-subj': isChartHidden ? 'dscShowHistogramButton' : 'dscHideHistogramButton',
'aria-expanded': !isChartHidden,
'aria-controls': 'unifiedHistogramCollapsablePanel',
onClick: onToggleChart,
},
]
: []),
];
if (!buttons.length) {
return null;
}
return (
<IconButtonGroup
data-test-subj={`dscPanelsToggle${isInsideHistogram ? 'InHistogram' : 'InPage'}`}
legend={i18n.translate('discover.panelsToggle.panelsVisibilityLegend', {
defaultMessage: 'Panels visibility',
})}
buttonSize="s"
buttons={buttons}
/>
);
};

View file

@ -11,12 +11,18 @@ import { VIEW_MODE } from '../../../common/constants';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import React from 'react';
import { findTestSubject } from '@elastic/eui/lib/test';
import { DocumentViewModeToggle } from './view_mode_toggle';
import { BehaviorSubject } from 'rxjs';
import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock';
import { DataTotalHits$ } from '../../application/main/services/discover_data_state_container';
import { FetchStatus } from '../../application/types';
describe('Document view mode toggle component', () => {
const mountComponent = ({
showFieldStatistics = true,
viewMode = VIEW_MODE.DOCUMENT_LEVEL,
isTextBasedQuery = false,
setDiscoverViewMode = jest.fn(),
} = {}) => {
const serivces = {
@ -25,21 +31,40 @@ describe('Document view mode toggle component', () => {
},
};
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: 10,
}) as DataTotalHits$;
return mountWithIntl(
<KibanaContextProvider services={serivces}>
<DocumentViewModeToggle viewMode={viewMode} setDiscoverViewMode={setDiscoverViewMode} />
<DocumentViewModeToggle
viewMode={viewMode}
isTextBasedQuery={isTextBasedQuery}
stateContainer={stateContainer}
setDiscoverViewMode={setDiscoverViewMode}
/>
</KibanaContextProvider>
);
};
it('should render if SHOW_FIELD_STATISTICS is true', () => {
const component = mountComponent();
expect(component.isEmptyRender()).toBe(false);
expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(true);
expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true);
});
it('should not render if SHOW_FIELD_STATISTICS is false', () => {
const component = mountComponent({ showFieldStatistics: false });
expect(component.isEmptyRender()).toBe(true);
expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(false);
expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true);
});
it('should not render if text-based', () => {
const component = mountComponent({ isTextBasedQuery: true });
expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(false);
expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true);
});
it('should set the view mode to VIEW_MODE.DOCUMENT_LEVEL when dscViewModeDocumentButton is clicked', () => {

View file

@ -6,19 +6,27 @@
* Side Public License, v 1.
*/
import React, { useMemo } from 'react';
import { EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui';
import React, { useMemo, ReactElement } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { DOC_TABLE_LEGACY, SHOW_FIELD_STATISTICS } from '@kbn/discover-utils';
import { VIEW_MODE } from '../../../common/constants';
import { useDiscoverServices } from '../../hooks/use_discover_services';
import { DiscoverStateContainer } from '../../application/main/services/discover_state';
import { HitsCounter, HitsCounterMode } from '../hits_counter';
export const DocumentViewModeToggle = ({
viewMode,
isTextBasedQuery,
prepend,
stateContainer,
setDiscoverViewMode,
}: {
viewMode: VIEW_MODE;
isTextBasedQuery: boolean;
prepend?: ReactElement;
stateContainer: DiscoverStateContainer;
setDiscoverViewMode: (viewMode: VIEW_MODE) => void;
}) => {
const { euiTheme } = useEuiTheme();
@ -26,10 +34,12 @@ export const DocumentViewModeToggle = ({
const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]);
const includesNormalTabsStyle = viewMode === VIEW_MODE.AGGREGATED_LEVEL || isLegacy;
const tabsPadding = includesNormalTabsStyle ? euiTheme.size.s : 0;
const tabsCss = css`
padding: ${tabsPadding} ${tabsPadding} 0 ${tabsPadding};
const containerPadding = includesNormalTabsStyle ? euiTheme.size.s : 0;
const containerCss = css`
padding: ${containerPadding} ${containerPadding} 0 ${containerPadding};
`;
const tabsCss = css`
.euiTab__content {
line-height: ${euiTheme.size.xl};
}
@ -37,29 +47,52 @@ export const DocumentViewModeToggle = ({
const showViewModeToggle = uiSettings.get(SHOW_FIELD_STATISTICS) ?? false;
if (!showViewModeToggle) {
return null;
}
return (
<EuiTabs size="m" css={tabsCss} data-test-subj="dscViewModeToggle" bottomBorder={false}>
<EuiTab
isSelected={viewMode === VIEW_MODE.DOCUMENT_LEVEL}
onClick={() => setDiscoverViewMode(VIEW_MODE.DOCUMENT_LEVEL)}
data-test-subj="dscViewModeDocumentButton"
>
<FormattedMessage id="discover.viewModes.document.label" defaultMessage="Documents" />
</EuiTab>
<EuiTab
isSelected={viewMode === VIEW_MODE.AGGREGATED_LEVEL}
onClick={() => setDiscoverViewMode(VIEW_MODE.AGGREGATED_LEVEL)}
data-test-subj="dscViewModeFieldStatsButton"
>
<FormattedMessage
id="discover.viewModes.fieldStatistics.label"
defaultMessage="Field statistics"
/>
</EuiTab>
</EuiTabs>
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
responsive={false}
css={containerCss}
>
{prepend && (
<EuiFlexItem
grow={false}
css={css`
&:empty {
display: none;
}
`}
>
{prepend}
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
{isTextBasedQuery || !showViewModeToggle ? (
<HitsCounter mode={HitsCounterMode.standalone} stateContainer={stateContainer} />
) : (
<EuiTabs size="m" css={tabsCss} data-test-subj="dscViewModeToggle" bottomBorder={false}>
<EuiTab
isSelected={viewMode === VIEW_MODE.DOCUMENT_LEVEL}
onClick={() => setDiscoverViewMode(VIEW_MODE.DOCUMENT_LEVEL)}
data-test-subj="dscViewModeDocumentButton"
>
<FormattedMessage id="discover.viewModes.document.label" defaultMessage="Documents" />
<HitsCounter mode={HitsCounterMode.appended} stateContainer={stateContainer} />
</EuiTab>
<EuiTab
isSelected={viewMode === VIEW_MODE.AGGREGATED_LEVEL}
onClick={() => setDiscoverViewMode(VIEW_MODE.AGGREGATED_LEVEL)}
data-test-subj="dscViewModeFieldStatsButton"
>
<FormattedMessage
id="discover.viewModes.fieldStatistics.label"
defaultMessage="Field statistics"
/>
</EuiTab>
</EuiTabs>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -78,6 +78,7 @@
"@kbn/rule-data-utils",
"@kbn/core-chrome-browser",
"@kbn/core-plugins-server",
"@kbn/shared-ux-button-toolbar",
"@kbn/serverless",
"@kbn/deeplinks-observability"
],

View file

@ -49,9 +49,6 @@ return (
// Pass a ref to the containing element to
// handle top panel resize functionality
resizeRef={resizeRef}
// Optionally append an element after the
// hits counter display
appendHitsCounter={<MyButton />}
>
<MyLayout />
</UnifiedHistogramContainer>
@ -165,7 +162,6 @@ return (
searchSessionId={searchSessionId}
requestAdapter={requestAdapter}
resizeRef={resizeRef}
appendHitsCounter={<MyButton />}
>
<MyLayout />
</UnifiedHistogramContainer>

View file

@ -6,68 +6,132 @@
* Side Public License, v 1.
*/
import { EuiComboBox } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { render, act, screen } from '@testing-library/react';
import React from 'react';
import { UnifiedHistogramBreakdownContext } from '../types';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { BreakdownFieldSelector } from './breakdown_field_selector';
import { fieldSupportsBreakdown } from './utils/field_supports_breakdown';
describe('BreakdownFieldSelector', () => {
it('should pass fields that support breakdown as options to the EuiComboBox', () => {
it('should render correctly', () => {
const onBreakdownFieldChange = jest.fn();
const breakdown: UnifiedHistogramBreakdownContext = {
field: undefined,
};
const wrapper = mountWithIntl(
render(
<BreakdownFieldSelector
dataView={dataViewWithTimefieldMock}
breakdown={breakdown}
onBreakdownFieldChange={onBreakdownFieldChange}
/>
);
const comboBox = wrapper.find(EuiComboBox);
expect(comboBox.prop('options')).toEqual(
dataViewWithTimefieldMock.fields
.filter(fieldSupportsBreakdown)
.map((field) => ({ label: field.displayName, value: field.name }))
.sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase()))
);
const button = screen.getByTestId('unifiedHistogramBreakdownSelectorButton');
expect(button.getAttribute('data-selected-value')).toBe(null);
act(() => {
button.click();
});
const options = screen.getAllByRole('option');
expect(
options.map((option) => ({
label: option.getAttribute('title'),
value: option.getAttribute('value'),
checked: option.getAttribute('aria-checked'),
}))
).toMatchInlineSnapshot(`
Array [
Object {
"checked": "true",
"label": "No breakdown",
"value": "__EMPTY_SELECTOR_OPTION__",
},
Object {
"checked": "false",
"label": "bytes",
"value": "bytes",
},
Object {
"checked": "false",
"label": "extension",
"value": "extension",
},
]
`);
});
it('should pass selectedOptions to the EuiComboBox if breakdown.field is defined', () => {
it('should mark the option as checked if breakdown.field is defined', () => {
const onBreakdownFieldChange = jest.fn();
const field = dataViewWithTimefieldMock.fields.find((f) => f.name === 'extension')!;
const breakdown: UnifiedHistogramBreakdownContext = { field };
const wrapper = mountWithIntl(
render(
<BreakdownFieldSelector
dataView={dataViewWithTimefieldMock}
breakdown={breakdown}
onBreakdownFieldChange={onBreakdownFieldChange}
/>
);
const comboBox = wrapper.find(EuiComboBox);
expect(comboBox.prop('selectedOptions')).toEqual([
{ label: field.displayName, value: field.name },
]);
const button = screen.getByTestId('unifiedHistogramBreakdownSelectorButton');
expect(button.getAttribute('data-selected-value')).toBe('extension');
act(() => {
button.click();
});
const options = screen.getAllByRole('option');
expect(
options.map((option) => ({
label: option.getAttribute('title'),
value: option.getAttribute('value'),
checked: option.getAttribute('aria-checked'),
}))
).toMatchInlineSnapshot(`
Array [
Object {
"checked": "false",
"label": "No breakdown",
"value": "__EMPTY_SELECTOR_OPTION__",
},
Object {
"checked": "false",
"label": "bytes",
"value": "bytes",
},
Object {
"checked": "true",
"label": "extension",
"value": "extension",
},
]
`);
});
it('should call onBreakdownFieldChange with the selected field when the user selects a field', () => {
const onBreakdownFieldChange = jest.fn();
const selectedField = dataViewWithTimefieldMock.fields.find((f) => f.name === 'bytes')!;
const breakdown: UnifiedHistogramBreakdownContext = {
field: undefined,
};
const wrapper = mountWithIntl(
render(
<BreakdownFieldSelector
dataView={dataViewWithTimefieldMock}
breakdown={breakdown}
onBreakdownFieldChange={onBreakdownFieldChange}
/>
);
const comboBox = wrapper.find(EuiComboBox);
const selectedField = dataViewWithTimefieldMock.fields.find((f) => f.name === 'extension')!;
comboBox.prop('onChange')!([{ label: selectedField.displayName, value: selectedField.name }]);
act(() => {
screen.getByTestId('unifiedHistogramBreakdownSelectorButton').click();
});
act(() => {
screen.getByTitle('bytes').click();
});
expect(onBreakdownFieldChange).toHaveBeenCalledWith(selectedField);
});
});

View file

@ -6,14 +6,20 @@
* Side Public License, v 1.
*/
import { EuiComboBox, EuiComboBoxOptionOption, EuiToolTip, useEuiTheme } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { EuiSelectableOption } from '@elastic/eui';
import { FieldIcon, getFieldIconProps } from '@kbn/field-utils';
import { css } from '@emotion/react';
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { calculateWidthFromEntries } from '@kbn/calculate-width-from-char-count';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useState } from 'react';
import { UnifiedHistogramBreakdownContext } from '../types';
import { fieldSupportsBreakdown } from './utils/field_supports_breakdown';
import {
ToolbarSelector,
ToolbarSelectorProps,
EMPTY_OPTION,
SelectableEntry,
} from './toolbar_selector';
export interface BreakdownFieldSelectorProps {
dataView: DataView;
@ -21,77 +27,83 @@ export interface BreakdownFieldSelectorProps {
onBreakdownFieldChange?: (breakdownField: DataViewField | undefined) => void;
}
const TRUNCATION_PROPS = { truncation: 'middle' as const };
const SINGLE_SELECTION = { asPlainText: true };
export const BreakdownFieldSelector = ({
dataView,
breakdown,
onBreakdownFieldChange,
}: BreakdownFieldSelectorProps) => {
const fieldOptions = dataView.fields
.filter(fieldSupportsBreakdown)
.map((field) => ({ label: field.displayName, value: field.name }))
.sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase()));
const fieldOptions: SelectableEntry[] = useMemo(() => {
const options: SelectableEntry[] = dataView.fields
.filter(fieldSupportsBreakdown)
.map((field) => ({
key: field.name,
label: field.displayName,
value: field.name,
checked:
breakdown?.field?.name === field.name
? ('on' as EuiSelectableOption['checked'])
: undefined,
prepend: (
<span
css={css`
.euiToken {
vertical-align: middle;
}
`}
>
<FieldIcon {...getFieldIconProps(field)} />
</span>
),
}))
.sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase()));
const selectedFields = breakdown.field
? [{ label: breakdown.field.displayName, value: breakdown.field.name }]
: [];
options.unshift({
key: EMPTY_OPTION,
value: EMPTY_OPTION,
label: i18n.translate('unifiedHistogram.breakdownFieldSelector.noBreakdownButtonLabel', {
defaultMessage: 'No breakdown',
}),
checked: !breakdown?.field ? ('on' as EuiSelectableOption['checked']) : undefined,
});
const onFieldChange = useCallback(
(newOptions: EuiComboBoxOptionOption[]) => {
const field = newOptions.length
? dataView.fields.find((currentField) => currentField.name === newOptions[0].value)
return options;
}, [dataView, breakdown.field]);
const onChange: ToolbarSelectorProps['onChange'] = useCallback(
(chosenOption) => {
const field = chosenOption?.value
? dataView.fields.find((currentField) => currentField.name === chosenOption.value)
: undefined;
onBreakdownFieldChange?.(field);
},
[dataView.fields, onBreakdownFieldChange]
);
const [fieldPopoverDisabled, setFieldPopoverDisabled] = useState(false);
const disableFieldPopover = useCallback(() => setFieldPopoverDisabled(true), []);
const enableFieldPopover = useCallback(
() => setTimeout(() => setFieldPopoverDisabled(false)),
[]
);
const { euiTheme } = useEuiTheme();
const breakdownCss = css`
width: 100%;
max-width: ${euiTheme.base * 22}px;
`;
const panelMinWidth = calculateWidthFromEntries(fieldOptions, ['label']);
return (
<EuiToolTip
position="top"
content={fieldPopoverDisabled ? undefined : breakdown.field?.displayName}
anchorProps={{ css: breakdownCss }}
>
<EuiComboBox
data-test-subj="unifiedHistogramBreakdownFieldSelector"
prepend={i18n.translate('unifiedHistogram.breakdownFieldSelectorLabel', {
defaultMessage: 'Break down by',
})}
placeholder={i18n.translate('unifiedHistogram.breakdownFieldSelectorPlaceholder', {
defaultMessage: 'Select field',
})}
aria-label={i18n.translate('unifiedHistogram.breakdownFieldSelectorAriaLabel', {
defaultMessage: 'Break down by',
})}
inputPopoverProps={{ panelMinWidth, anchorPosition: 'downRight' }}
singleSelection={SINGLE_SELECTION}
options={fieldOptions}
selectedOptions={selectedFields}
onChange={onFieldChange}
truncationProps={TRUNCATION_PROPS}
compressed
fullWidth={true}
onFocus={disableFieldPopover}
onBlur={enableFieldPopover}
/>
</EuiToolTip>
<ToolbarSelector
data-test-subj="unifiedHistogramBreakdownSelector"
data-selected-value={breakdown?.field?.name}
searchable
buttonLabel={
breakdown?.field?.displayName
? i18n.translate('unifiedHistogram.breakdownFieldSelector.breakdownByButtonLabel', {
defaultMessage: 'Breakdown by {fieldName}',
values: {
fieldName: breakdown?.field?.displayName,
},
})
: i18n.translate('unifiedHistogram.breakdownFieldSelector.noBreakdownButtonLabel', {
defaultMessage: 'No breakdown',
})
}
popoverTitle={i18n.translate(
'unifiedHistogram.breakdownFieldSelector.breakdownFieldPopoverTitle',
{
defaultMessage: 'Select breakdown field',
}
)}
options={fieldOptions}
onChange={onChange}
/>
);
};

View file

@ -18,11 +18,11 @@ import type { ReactWrapper } from 'enzyme';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { of } from 'rxjs';
import { HitsCounter } from '../hits_counter';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { dataViewMock } from '../__mocks__/data_view';
import { BreakdownFieldSelector } from './breakdown_field_selector';
import { SuggestionSelector } from './suggestion_selector';
import { checkChartAvailability } from './check_chart_availability';
import { currentSuggestionMock, allSuggestionsMock } from '../__mocks__/suggestions';
@ -33,6 +33,7 @@ jest.mock('./hooks/use_edit_visualization', () => ({
}));
async function mountComponent({
customToggle,
noChart,
noHits,
noBreakdown,
@ -45,6 +46,7 @@ async function mountComponent({
hasDashboardPermissions,
isChartLoading,
}: {
customToggle?: ReactElement;
noChart?: boolean;
noHits?: boolean;
noBreakdown?: boolean;
@ -70,6 +72,19 @@ async function mountComponent({
} as unknown as Capabilities,
};
const chart = noChart
? undefined
: {
status: 'complete' as UnifiedHistogramFetchStatus,
hidden: chartHidden,
timeInterval: 'auto',
bucketInterval: {
scaled: true,
description: 'test',
scale: 2,
},
};
const props = {
dataView,
query: {
@ -85,28 +100,18 @@ async function mountComponent({
status: 'complete' as UnifiedHistogramFetchStatus,
number: 2,
},
chart: noChart
? undefined
: {
status: 'complete' as UnifiedHistogramFetchStatus,
hidden: chartHidden,
timeInterval: 'auto',
bucketInterval: {
scaled: true,
description: 'test',
scale: 2,
},
},
chart,
breakdown: noBreakdown ? undefined : { field: undefined },
currentSuggestion,
allSuggestions,
isChartLoading: Boolean(isChartLoading),
isPlainRecord,
appendHistogram,
onResetChartHeight: jest.fn(),
onChartHiddenChange: jest.fn(),
onTimeIntervalChange: jest.fn(),
withDefaultActions: undefined,
isChartAvailable: checkChartAvailability({ chart, dataView, isPlainRecord }),
renderCustomChartToggleActions: customToggle ? () => customToggle : undefined,
};
let instance: ReactWrapper = {} as ReactWrapper;
@ -126,16 +131,33 @@ describe('Chart', () => {
test('render when chart is undefined', async () => {
const component = await mountComponent({ noChart: true });
expect(
component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists()
).toBeFalsy();
expect(component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists()).toBe(
true
);
});
test('should render a custom toggle when provided', async () => {
const component = await mountComponent({
customToggle: <span data-test-subj="custom-toggle" />,
});
expect(component.find('[data-test-subj="custom-toggle"]').exists()).toBe(true);
expect(component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists()).toBe(
false
);
});
test('should not render when custom toggle is provided and chart is hidden', async () => {
const component = await mountComponent({ customToggle: <span />, chartHidden: true });
expect(component.find('[data-test-subj="unifiedHistogramChartPanelHidden"]').exists()).toBe(
true
);
});
test('render when chart is defined and onEditVisualization is undefined', async () => {
mockUseEditVisualization = undefined;
const component = await mountComponent();
expect(
component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists()
component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists()
).toBeTruthy();
expect(
component.find('[data-test-subj="unifiedHistogramEditVisualization"]').exists()
@ -145,7 +167,7 @@ describe('Chart', () => {
test('render when chart is defined and onEditVisualization is defined', async () => {
const component = await mountComponent();
expect(
component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists()
component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists()
).toBeTruthy();
expect(
component.find('[data-test-subj="unifiedHistogramEditVisualization"]').exists()
@ -155,7 +177,7 @@ describe('Chart', () => {
test('render when chart.hidden is true', async () => {
const component = await mountComponent({ chartHidden: true });
expect(
component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists()
component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists()
).toBeTruthy();
expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBeFalsy();
});
@ -163,7 +185,7 @@ describe('Chart', () => {
test('render when chart.hidden is false', async () => {
const component = await mountComponent({ chartHidden: false });
expect(
component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists()
component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists()
).toBeTruthy();
expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBeTruthy();
});
@ -171,7 +193,7 @@ describe('Chart', () => {
test('render when is text based and not timebased', async () => {
const component = await mountComponent({ isPlainRecord: true, dataView: dataViewMock });
expect(
component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists()
component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists()
).toBeTruthy();
expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBeTruthy();
});
@ -187,22 +209,12 @@ describe('Chart', () => {
await act(async () => {
component
.find('[data-test-subj="unifiedHistogramEditVisualization"]')
.first()
.last()
.simulate('click');
});
expect(mockUseEditVisualization).toHaveBeenCalled();
});
it('should render HitsCounter when hits is defined', async () => {
const component = await mountComponent();
expect(component.find(HitsCounter).exists()).toBeTruthy();
});
it('should not render HitsCounter when hits is undefined', async () => {
const component = await mountComponent({ noHits: true });
expect(component.find(HitsCounter).exists()).toBeFalsy();
});
it('should render the element passed to appendHistogram', async () => {
const appendHistogram = <div data-test-subj="appendHistogram" />;
const component = await mountComponent({ appendHistogram });

View file

@ -8,28 +8,19 @@
import React, { ReactElement, useMemo, useState, useEffect, useCallback, memo } from 'react';
import type { Observable } from 'rxjs';
import {
EuiButtonIcon,
EuiContextMenu,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiToolTip,
EuiProgress,
} from '@elastic/eui';
import { IconButtonGroup, type IconButtonGroupProps } from '@kbn/shared-ux-button-toolbar';
import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type {
EmbeddableComponentProps,
Suggestion,
LensEmbeddableOutput,
} from '@kbn/lens-plugin/public';
import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/public';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { LensEmbeddableInput } from '@kbn/lens-plugin/public';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { Subject } from 'rxjs';
import { HitsCounter } from '../hits_counter';
import { Histogram } from './histogram';
import { useChartPanels } from './hooks/use_chart_panels';
import type {
UnifiedHistogramBreakdownContext,
UnifiedHistogramChartContext,
@ -43,6 +34,7 @@ import type {
} from '../types';
import { BreakdownFieldSelector } from './breakdown_field_selector';
import { SuggestionSelector } from './suggestion_selector';
import { TimeIntervalSelector } from './time_interval_selector';
import { useTotalHits } from './hooks/use_total_hits';
import { useRequestParams } from './hooks/use_request_params';
import { useChartStyles } from './hooks/use_chart_styles';
@ -53,6 +45,8 @@ import { useRefetch } from './hooks/use_refetch';
import { useEditVisualization } from './hooks/use_edit_visualization';
export interface ChartProps {
isChartAvailable: boolean;
hiddenPanel?: boolean;
className?: string;
services: UnifiedHistogramServices;
dataView: DataView;
@ -67,7 +61,7 @@ export interface ChartProps {
hits?: UnifiedHistogramHitsContext;
chart?: UnifiedHistogramChartContext;
breakdown?: UnifiedHistogramBreakdownContext;
appendHitsCounter?: ReactElement;
renderCustomChartToggleActions?: () => ReactElement | undefined;
appendHistogram?: ReactElement;
disableAutoFetching?: boolean;
disableTriggers?: LensEmbeddableInput['disableTriggers'];
@ -78,7 +72,6 @@ export interface ChartProps {
isOnHistogramMode?: boolean;
histogramQuery?: AggregateQuery;
isChartLoading?: boolean;
onResetChartHeight?: () => void;
onChartHiddenChange?: (chartHidden: boolean) => void;
onTimeIntervalChange?: (timeInterval: string) => void;
onBreakdownFieldChange?: (breakdownField: DataViewField | undefined) => void;
@ -93,6 +86,7 @@ export interface ChartProps {
const HistogramMemoized = memo(Histogram);
export function Chart({
isChartAvailable,
className,
services,
dataView,
@ -107,7 +101,7 @@ export function Chart({
currentSuggestion,
allSuggestions,
isPlainRecord,
appendHitsCounter,
renderCustomChartToggleActions,
appendHistogram,
disableAutoFetching,
disableTriggers,
@ -118,7 +112,6 @@ export function Chart({
isOnHistogramMode,
histogramQuery,
isChartLoading,
onResetChartHeight,
onChartHiddenChange,
onTimeIntervalChange,
onSuggestionChange,
@ -131,33 +124,12 @@ export function Chart({
}: ChartProps) {
const [isSaveModalVisible, setIsSaveModalVisible] = useState(false);
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
const {
showChartOptionsPopover,
chartRef,
toggleChartOptions,
closeChartOptions,
toggleHideChart,
} = useChartActions({
const { chartRef, toggleHideChart } = useChartActions({
chart,
onChartHiddenChange,
});
const panels = useChartPanels({
chart,
toggleHideChart,
onTimeIntervalChange,
closePopover: closeChartOptions,
onResetChartHeight,
isPlainRecord,
});
const chartVisible = !!(
chart &&
!chart.hidden &&
dataView.id &&
dataView.type !== DataViewType.ROLLUP &&
(isPlainRecord || (!isPlainRecord && dataView.isTimeBased()))
);
const chartVisible = isChartAvailable && !!chart && !chart.hidden;
const input$ = useMemo(
() => originalInput$ ?? new Subject<UnifiedHistogramInputMessage>(),
@ -201,17 +173,7 @@ export function Chart({
isPlainRecord,
});
const {
resultCountCss,
resultCountInnerCss,
resultCountTitleCss,
resultCountToggleCss,
histogramCss,
breakdownFieldSelectorGroupCss,
breakdownFieldSelectorItemCss,
suggestionsSelectorItemCss,
chartToolButtonCss,
} = useChartStyles(chartVisible);
const { chartToolbarCss, histogramCss } = useChartStyles(chartVisible);
const lensAttributesContext = useMemo(
() =>
@ -258,162 +220,135 @@ export function Chart({
lensAttributes: lensAttributesContext.attributes,
isPlainRecord,
});
const a11yCommonProps = {
id: 'unifiedHistogramCollapsablePanel',
};
if (Boolean(renderCustomChartToggleActions) && !chartVisible) {
return <div {...a11yCommonProps} data-test-subj="unifiedHistogramChartPanelHidden" />;
}
const LensSaveModalComponent = services.lens.SaveModalComponent;
const canSaveVisualization =
chartVisible && currentSuggestion && services.capabilities.dashboard?.showWriteControls;
const renderEditButton = useMemo(
() => (
<EuiButtonIcon
size="xs"
iconType="pencil"
onClick={() => setIsFlyoutVisible(true)}
data-test-subj="unifiedHistogramEditFlyoutVisualization"
aria-label={i18n.translate('unifiedHistogram.editVisualizationButton', {
defaultMessage: 'Edit visualization',
})}
disabled={isFlyoutVisible}
/>
),
[isFlyoutVisible]
);
const canEditVisualizationOnTheFly = currentSuggestion && chartVisible;
const actions: IconButtonGroupProps['buttons'] = [];
if (canEditVisualizationOnTheFly) {
actions.push({
label: i18n.translate('unifiedHistogram.editVisualizationButton', {
defaultMessage: 'Edit visualization',
}),
iconType: 'pencil',
isDisabled: isFlyoutVisible,
'data-test-subj': 'unifiedHistogramEditFlyoutVisualization',
onClick: () => setIsFlyoutVisible(true),
});
} else if (onEditVisualization) {
actions.push({
label: i18n.translate('unifiedHistogram.editVisualizationButton', {
defaultMessage: 'Edit visualization',
}),
iconType: 'lensApp',
'data-test-subj': 'unifiedHistogramEditVisualization',
onClick: onEditVisualization,
});
}
if (canSaveVisualization) {
actions.push({
label: i18n.translate('unifiedHistogram.saveVisualizationButton', {
defaultMessage: 'Save visualization',
}),
iconType: 'save',
'data-test-subj': 'unifiedHistogramSaveVisualization',
onClick: () => setIsSaveModalVisible(true),
});
}
return (
<EuiFlexGroup
{...a11yCommonProps}
className={className}
direction="column"
alignItems="stretch"
gutterSize="none"
responsive={false}
>
<EuiFlexItem grow={false} css={resultCountCss}>
<EuiFlexItem grow={false} css={chartToolbarCss}>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize="none"
direction="row"
gutterSize="s"
responsive={false}
css={resultCountInnerCss}
alignItems="center"
justifyContent="spaceBetween"
>
<EuiFlexItem
grow={false}
className="eui-textTruncate eui-textNoWrap"
css={resultCountTitleCss}
>
{hits && <HitsCounter hits={hits} append={appendHitsCounter} />}
</EuiFlexItem>
{chart && (
<EuiFlexItem css={resultCountToggleCss}>
<EuiFlexGroup
direction="row"
gutterSize="none"
responsive={false}
justifyContent="flexEnd"
css={breakdownFieldSelectorGroupCss}
>
{chartVisible && breakdown && (
<EuiFlexItem css={breakdownFieldSelectorItemCss}>
<EuiFlexItem grow={false} css={{ minWidth: 0 }}>
<EuiFlexGroup direction="row" gutterSize="s" responsive={false} alignItems="center">
<EuiFlexItem grow={false}>
{renderCustomChartToggleActions ? (
renderCustomChartToggleActions()
) : (
<IconButtonGroup
legend={i18n.translate('unifiedHistogram.hideChartButtongroupLegend', {
defaultMessage: 'Chart visibility',
})}
buttonSize="s"
buttons={[
{
label: chartVisible
? i18n.translate('unifiedHistogram.hideChartButton', {
defaultMessage: 'Hide chart',
})
: i18n.translate('unifiedHistogram.showChartButton', {
defaultMessage: 'Show chart',
}),
iconType: chartVisible ? 'transitionTopOut' : 'transitionTopIn',
'data-test-subj': 'unifiedHistogramToggleChartButton',
onClick: toggleHideChart,
},
]}
/>
)}
</EuiFlexItem>
{chartVisible && !isPlainRecord && !!onTimeIntervalChange && (
<EuiFlexItem grow={false} css={{ minWidth: 0 }}>
<TimeIntervalSelector chart={chart} onTimeIntervalChange={onTimeIntervalChange} />
</EuiFlexItem>
)}
<EuiFlexItem grow={false} css={{ minWidth: 0 }}>
<div>
{chartVisible && breakdown && (
<BreakdownFieldSelector
dataView={dataView}
breakdown={breakdown}
onBreakdownFieldChange={onBreakdownFieldChange}
/>
</EuiFlexItem>
)}
{chartVisible && currentSuggestion && allSuggestions && allSuggestions?.length > 1 && (
<EuiFlexItem css={suggestionsSelectorItemCss}>
<SuggestionSelector
suggestions={allSuggestions}
activeSuggestion={currentSuggestion}
onSuggestionChange={onSuggestionSelectorChange}
/>
</EuiFlexItem>
)}
{canSaveVisualization && (
<>
<EuiFlexItem grow={false} css={chartToolButtonCss}>
<EuiToolTip
content={i18n.translate('unifiedHistogram.saveVisualizationButton', {
defaultMessage: 'Save visualization',
})}
>
<EuiButtonIcon
size="xs"
iconType="save"
onClick={() => setIsSaveModalVisible(true)}
data-test-subj="unifiedHistogramSaveVisualization"
aria-label={i18n.translate('unifiedHistogram.saveVisualizationButton', {
defaultMessage: 'Save visualization',
})}
/>
</EuiToolTip>
</EuiFlexItem>
</>
)}
{canEditVisualizationOnTheFly && (
<EuiFlexItem grow={false} css={chartToolButtonCss}>
{!isFlyoutVisible ? (
<EuiToolTip
content={i18n.translate('unifiedHistogram.editVisualizationButton', {
defaultMessage: 'Edit visualization',
})}
>
{renderEditButton}
</EuiToolTip>
) : (
renderEditButton
)}
</EuiFlexItem>
)}
{onEditVisualization && (
<EuiFlexItem grow={false} css={chartToolButtonCss}>
<EuiToolTip
content={i18n.translate('unifiedHistogram.editVisualizationButton', {
defaultMessage: 'Edit visualization',
})}
>
<EuiButtonIcon
size="xs"
iconType="lensApp"
onClick={onEditVisualization}
data-test-subj="unifiedHistogramEditVisualization"
aria-label={i18n.translate('unifiedHistogram.editVisualizationButton', {
defaultMessage: 'Edit visualization',
})}
)}
{chartVisible &&
currentSuggestion &&
allSuggestions &&
allSuggestions?.length > 1 && (
<SuggestionSelector
suggestions={allSuggestions}
activeSuggestion={currentSuggestion}
onSuggestionChange={onSuggestionSelectorChange}
/>
</EuiToolTip>
</EuiFlexItem>
)}
<EuiFlexItem grow={false} css={chartToolButtonCss}>
<EuiPopover
id="unifiedHistogramChartOptions"
button={
<EuiToolTip
content={i18n.translate('unifiedHistogram.chartOptionsButton', {
defaultMessage: 'Chart options',
})}
>
<EuiButtonIcon
size="xs"
iconType="gear"
onClick={toggleChartOptions}
data-test-subj="unifiedHistogramChartOptionsToggle"
aria-label={i18n.translate('unifiedHistogram.chartOptionsButton', {
defaultMessage: 'Chart options',
})}
/>
</EuiToolTip>
}
isOpen={showChartOptionsPopover}
closePopover={closeChartOptions}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{chartVisible && actions.length > 0 && (
<EuiFlexItem grow={false}>
<IconButtonGroup
legend={i18n.translate('unifiedHistogram.chartActionsGroupLegend', {
defaultMessage: 'Chart actions',
})}
buttonSize="s"
buttons={actions}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
@ -427,6 +362,7 @@ export function Chart({
defaultMessage: 'Histogram of found documents',
})}
css={histogramCss}
data-test-subj="unifiedHistogramRendered"
>
{isChartLoading && (
<EuiProgress

View file

@ -0,0 +1,27 @@
/*
* 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 { type DataView, DataViewType } from '@kbn/data-views-plugin/common';
import { UnifiedHistogramChartContext } from '../types';
export function checkChartAvailability({
chart,
dataView,
isPlainRecord,
}: {
chart?: UnifiedHistogramChartContext;
dataView: DataView;
isPlainRecord?: boolean;
}): boolean {
return Boolean(
chart &&
dataView.id &&
dataView.type !== DataViewType.ROLLUP &&
(isPlainRecord || (!isPlainRecord && dataView.isTimeBased()))
);
}

View file

@ -70,8 +70,12 @@ const computeTotalHits = (
return Object.values(adapterTables ?? {})?.[0]?.rows?.length;
} else if (isPlainRecord && !hasLensSuggestions) {
// ES|QL histogram case
const rows = Object.values(adapterTables ?? {})?.[0]?.rows;
if (!rows) {
return undefined;
}
let rowsCount = 0;
Object.values(adapterTables ?? {})?.[0]?.rows.forEach((r) => {
rows.forEach((r) => {
rowsCount += r.rows;
});
return rowsCount;

View file

@ -27,31 +27,6 @@ describe('useChartActions', () => {
};
};
it('should toggle chart options', () => {
const { hook } = render();
expect(hook.result.current.showChartOptionsPopover).toBe(false);
act(() => {
hook.result.current.toggleChartOptions();
});
expect(hook.result.current.showChartOptionsPopover).toBe(true);
act(() => {
hook.result.current.toggleChartOptions();
});
expect(hook.result.current.showChartOptionsPopover).toBe(false);
});
it('should close chart options', () => {
const { hook } = render();
act(() => {
hook.result.current.toggleChartOptions();
});
expect(hook.result.current.showChartOptionsPopover).toBe(true);
act(() => {
hook.result.current.closeChartOptions();
});
expect(hook.result.current.showChartOptionsPopover).toBe(false);
});
it('should toggle hide chart', () => {
const { chart, onChartHiddenChange, hook } = render();
act(() => {

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type { UnifiedHistogramChartContext } from '../../types';
export const useChartActions = ({
@ -16,16 +16,6 @@ export const useChartActions = ({
chart: UnifiedHistogramChartContext | undefined;
onChartHiddenChange?: (chartHidden: boolean) => void;
}) => {
const [showChartOptionsPopover, setShowChartOptionsPopover] = useState(false);
const toggleChartOptions = useCallback(() => {
setShowChartOptionsPopover(!showChartOptionsPopover);
}, [showChartOptionsPopover]);
const closeChartOptions = useCallback(() => {
setShowChartOptionsPopover(false);
}, [setShowChartOptionsPopover]);
const chartRef = useRef<{ element: HTMLElement | null; moveFocus: boolean }>({
element: null,
moveFocus: false,
@ -44,10 +34,7 @@ export const useChartActions = ({
}, [chart?.hidden, onChartHiddenChange]);
return {
showChartOptionsPopover,
chartRef,
toggleChartOptions,
closeChartOptions,
toggleHideChart,
};
};

View file

@ -1,108 +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 { renderHook } from '@testing-library/react-hooks';
import { useChartPanels } from './use_chart_panels';
import { EuiContextMenuPanelDescriptor } from '@elastic/eui';
describe('test useChartPanels', () => {
test('useChartsPanel when hideChart is true', async () => {
const { result } = renderHook(() => {
return useChartPanels({
toggleHideChart: jest.fn(),
onTimeIntervalChange: jest.fn(),
closePopover: jest.fn(),
onResetChartHeight: jest.fn(),
chart: {
hidden: true,
timeInterval: 'auto',
},
});
});
const panels: EuiContextMenuPanelDescriptor[] = result.current;
const panel0: EuiContextMenuPanelDescriptor = result.current[0];
expect(panels.length).toBe(1);
expect(panel0!.items).toHaveLength(1);
expect(panel0!.items![0].icon).toBe('eye');
});
test('useChartsPanel when hideChart is false', async () => {
const { result } = renderHook(() => {
return useChartPanels({
toggleHideChart: jest.fn(),
onTimeIntervalChange: jest.fn(),
closePopover: jest.fn(),
onResetChartHeight: jest.fn(),
chart: {
hidden: false,
timeInterval: 'auto',
},
});
});
const panels: EuiContextMenuPanelDescriptor[] = result.current;
const panel0: EuiContextMenuPanelDescriptor = result.current[0];
expect(panels.length).toBe(2);
expect(panel0!.items).toHaveLength(3);
expect(panel0!.items![0].icon).toBe('eyeClosed');
expect(panel0!.items![1].icon).toBe('refresh');
});
test('should not show reset chart height when onResetChartHeight is undefined', async () => {
const { result } = renderHook(() => {
return useChartPanels({
toggleHideChart: jest.fn(),
onTimeIntervalChange: jest.fn(),
closePopover: jest.fn(),
chart: {
hidden: false,
timeInterval: 'auto',
},
});
});
const panel0: EuiContextMenuPanelDescriptor = result.current[0];
expect(panel0!.items).toHaveLength(2);
expect(panel0!.items![0].icon).toBe('eyeClosed');
});
test('onResetChartHeight is called when the reset chart height button is clicked', async () => {
const onResetChartHeight = jest.fn();
const { result } = renderHook(() => {
return useChartPanels({
toggleHideChart: jest.fn(),
onTimeIntervalChange: jest.fn(),
closePopover: jest.fn(),
onResetChartHeight,
chart: {
hidden: false,
timeInterval: 'auto',
},
});
});
const panel0: EuiContextMenuPanelDescriptor = result.current[0];
const resetChartHeightButton = panel0!.items![1];
(resetChartHeightButton.onClick as Function)();
expect(onResetChartHeight).toBeCalled();
});
test('useChartsPanel when isPlainRecord', async () => {
const { result } = renderHook(() => {
return useChartPanels({
toggleHideChart: jest.fn(),
onTimeIntervalChange: jest.fn(),
closePopover: jest.fn(),
onResetChartHeight: jest.fn(),
isPlainRecord: true,
chart: {
hidden: true,
timeInterval: 'auto',
},
});
});
const panels: EuiContextMenuPanelDescriptor[] = result.current;
const panel0: EuiContextMenuPanelDescriptor = result.current[0];
expect(panels.length).toBe(1);
expect(panel0!.items).toHaveLength(1);
expect(panel0!.items![0].icon).toBe('eye');
});
});

View file

@ -1,124 +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 { i18n } from '@kbn/i18n';
import type {
EuiContextMenuPanelItemDescriptor,
EuiContextMenuPanelDescriptor,
} from '@elastic/eui';
import { search } from '@kbn/data-plugin/public';
import type { UnifiedHistogramChartContext } from '../../types';
export function useChartPanels({
chart,
toggleHideChart,
onTimeIntervalChange,
closePopover,
onResetChartHeight,
isPlainRecord,
}: {
chart?: UnifiedHistogramChartContext;
toggleHideChart: () => void;
onTimeIntervalChange?: (timeInterval: string) => void;
closePopover: () => void;
onResetChartHeight?: () => void;
isPlainRecord?: boolean;
}) {
if (!chart) {
return [];
}
const selectedOptionIdx = search.aggs.intervalOptions.findIndex(
(opt) => opt.val === chart.timeInterval
);
const intervalDisplay =
selectedOptionIdx > -1
? search.aggs.intervalOptions[selectedOptionIdx].display
: search.aggs.intervalOptions[0].display;
const mainPanelItems: EuiContextMenuPanelItemDescriptor[] = [
{
name: !chart.hidden
? i18n.translate('unifiedHistogram.hideChart', {
defaultMessage: 'Hide chart',
})
: i18n.translate('unifiedHistogram.showChart', {
defaultMessage: 'Show chart',
}),
icon: !chart.hidden ? 'eyeClosed' : 'eye',
onClick: () => {
toggleHideChart();
closePopover();
},
'data-test-subj': 'unifiedHistogramChartToggle',
},
];
if (!chart.hidden) {
if (onResetChartHeight) {
mainPanelItems.push({
name: i18n.translate('unifiedHistogram.resetChartHeight', {
defaultMessage: 'Reset to default height',
}),
icon: 'refresh',
onClick: () => {
onResetChartHeight();
closePopover();
},
'data-test-subj': 'unifiedHistogramChartResetHeight',
});
}
if (!isPlainRecord) {
mainPanelItems.push({
name: i18n.translate('unifiedHistogram.timeIntervalWithValue', {
defaultMessage: 'Time interval: {timeInterval}',
values: {
timeInterval: intervalDisplay,
},
}),
panel: 1,
'data-test-subj': 'unifiedHistogramTimeIntervalPanel',
});
}
}
const panels: EuiContextMenuPanelDescriptor[] = [
{
id: 0,
title: i18n.translate('unifiedHistogram.chartOptions', {
defaultMessage: 'Chart options',
}),
items: mainPanelItems,
},
];
if (!chart.hidden && !isPlainRecord) {
panels.push({
id: 1,
initialFocusedItemIndex: selectedOptionIdx > -1 ? selectedOptionIdx : 0,
title: i18n.translate('unifiedHistogram.timeIntervals', {
defaultMessage: 'Time intervals',
}),
items: search.aggs.intervalOptions
.filter(({ val }) => val !== 'custom')
.map(({ display, val }) => {
return {
name: display,
label: display,
icon: val === chart.timeInterval ? 'check' : 'empty',
onClick: () => {
onTimeIntervalChange?.(val);
closePopover();
},
'data-test-subj': `unifiedHistogramTimeInterval-${display}`,
className: val === chart.timeInterval ? 'unifiedHistogramIntervalSelected' : '',
};
}),
});
}
return panels;
}

View file

@ -6,36 +6,18 @@
* Side Public License, v 1.
*/
import { useEuiBreakpoint, useEuiTheme } from '@elastic/eui';
import { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
export const useChartStyles = (chartVisible: boolean) => {
const { euiTheme } = useEuiTheme();
const resultCountCss = css`
const chartToolbarCss = css`
padding: ${euiTheme.size.s} ${euiTheme.size.s} ${chartVisible ? 0 : euiTheme.size.s}
${euiTheme.size.s};
min-height: ${euiTheme.base * 2.5}px;
`;
const resultCountInnerCss = css`
${useEuiBreakpoint(['xs', 's'])} {
align-items: center;
}
`;
const resultCountTitleCss = css`
flex-basis: auto;
${useEuiBreakpoint(['xs', 's'])} {
margin-bottom: 0 !important;
}
`;
const resultCountToggleCss = css`
flex-basis: auto;
min-width: 0;
${useEuiBreakpoint(['xs', 's'])} {
align-items: flex-end;
}
`;
const histogramCss = css`
flex-grow: 1;
display: flex;
@ -48,34 +30,9 @@ export const useChartStyles = (chartVisible: boolean) => {
stroke-width: 1;
}
`;
const breakdownFieldSelectorGroupCss = css`
width: 100%;
`;
const breakdownFieldSelectorItemCss = css`
min-width: 0;
align-items: flex-end;
padding-left: ${euiTheme.size.s};
`;
const suggestionsSelectorItemCss = css`
min-width: 0;
align-items: flex-start;
padding-left: ${euiTheme.size.s};
`;
const chartToolButtonCss = css`
display: flex;
justify-content: center;
padding-left: ${euiTheme.size.s};
`;
return {
resultCountCss,
resultCountInnerCss,
resultCountTitleCss,
resultCountToggleCss,
chartToolbarCss,
histogramCss,
breakdownFieldSelectorGroupCss,
breakdownFieldSelectorItemCss,
suggestionsSelectorItemCss,
chartToolButtonCss,
};
};

View file

@ -85,7 +85,7 @@ describe('useTotalHits', () => {
const query = { query: 'test query', language: 'kuery' };
const filters: Filter[] = [{ meta: { index: 'test' }, query: { match_all: {} } }];
const adapter = new RequestAdapter();
renderHook(() =>
const { rerender } = renderHook(() =>
useTotalHits({
...getDeps(),
services: { data } as any,
@ -99,6 +99,8 @@ describe('useTotalHits', () => {
onTotalHitsChange,
})
);
refetch$.next({ type: 'refetch' });
rerender();
expect(onTotalHitsChange).toBeCalledTimes(1);
expect(onTotalHitsChange).toBeCalledWith(UnifiedHistogramFetchStatus.loading, undefined);
expect(setFieldSpy).toHaveBeenCalledWith('index', dataViewWithTimefieldMock);
@ -125,7 +127,9 @@ describe('useTotalHits', () => {
onTotalHitsChange,
query: { esql: 'from test' },
};
renderHook(() => useTotalHits(deps));
const { rerender } = renderHook(() => useTotalHits(deps));
refetch$.next({ type: 'refetch' });
rerender();
expect(onTotalHitsChange).toBeCalledTimes(1);
await waitFor(() => {
expect(deps.services.expressions.run).toBeCalledTimes(1);
@ -153,22 +157,16 @@ describe('useTotalHits', () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
it('should not fetch a second time if refetch$ is not triggered', async () => {
it('should not fetch if refetch$ is not triggered', async () => {
const onTotalHitsChange = jest.fn();
const fetchSpy = jest.spyOn(searchSourceInstanceMock, 'fetch$').mockClear();
const setFieldSpy = jest.spyOn(searchSourceInstanceMock, 'setField').mockClear();
const options = { ...getDeps(), onTotalHitsChange };
const { rerender } = renderHook(() => useTotalHits(options));
expect(onTotalHitsChange).toBeCalledTimes(1);
expect(setFieldSpy).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled();
await waitFor(() => {
expect(onTotalHitsChange).toBeCalledTimes(2);
});
rerender();
expect(onTotalHitsChange).toBeCalledTimes(2);
expect(setFieldSpy).toHaveBeenCalledTimes(5);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(onTotalHitsChange).toBeCalledTimes(0);
expect(setFieldSpy).toHaveBeenCalledTimes(0);
expect(fetchSpy).toHaveBeenCalledTimes(0);
});
it('should fetch a second time if refetch$ is triggered', async () => {
@ -178,6 +176,8 @@ describe('useTotalHits', () => {
const setFieldSpy = jest.spyOn(searchSourceInstanceMock, 'setField').mockClear();
const options = { ...getDeps(), onTotalHitsChange };
const { rerender } = renderHook(() => useTotalHits(options));
refetch$.next({ type: 'refetch' });
rerender();
expect(onTotalHitsChange).toBeCalledTimes(1);
expect(setFieldSpy).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled();
@ -202,7 +202,9 @@ describe('useTotalHits', () => {
.spyOn(searchSourceInstanceMock, 'fetch$')
.mockClear()
.mockReturnValue(throwError(() => error));
renderHook(() => useTotalHits({ ...getDeps(), onTotalHitsChange }));
const { rerender } = renderHook(() => useTotalHits({ ...getDeps(), onTotalHitsChange }));
refetch$.next({ type: 'refetch' });
rerender();
await waitFor(() => {
expect(onTotalHitsChange).toBeCalledTimes(2);
expect(onTotalHitsChange).toBeCalledWith(UnifiedHistogramFetchStatus.error, error);
@ -220,7 +222,7 @@ describe('useTotalHits', () => {
.mockClear()
.mockReturnValue(timeRange as any);
const filters: Filter[] = [{ meta: { index: 'test' }, query: { match_all: {} } }];
renderHook(() =>
const { rerender } = renderHook(() =>
useTotalHits({
...getDeps(),
dataView: {
@ -230,6 +232,8 @@ describe('useTotalHits', () => {
filters,
})
);
refetch$.next({ type: 'refetch' });
rerender();
expect(setOverwriteDataViewTypeSpy).toHaveBeenCalledWith(undefined);
expect(setFieldSpy).toHaveBeenCalledWith('filter', filters);
});

View file

@ -13,7 +13,6 @@ import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { Datatable, isExpressionValueError } from '@kbn/expressions-plugin/common';
import { i18n } from '@kbn/i18n';
import { MutableRefObject, useEffect, useRef } from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { catchError, filter, lastValueFrom, map, Observable, of, pluck } from 'rxjs';
import {
UnifiedHistogramFetchStatus,
@ -66,8 +65,6 @@ export const useTotalHits = ({
});
});
useEffectOnce(fetch);
useEffect(() => {
const subscription = refetch$.subscribe(fetch);
return () => subscription.unsubscribe();
@ -102,13 +99,11 @@ const fetchTotalHits = async ({
abortController.current?.abort();
abortController.current = undefined;
// Either the chart is visible, in which case Lens will make the request,
// or there is no hits context, which means the total hits should be hidden
if (chartVisible || !hits) {
if (chartVisible) {
return;
}
onTotalHitsChange?.(UnifiedHistogramFetchStatus.loading, hits.total);
onTotalHitsChange?.(UnifiedHistogramFetchStatus.loading, hits?.total);
const newAbortController = new AbortController();

View file

@ -7,3 +7,4 @@
*/
export { Chart } from './chart';
export { checkChartAvailability } from './check_chart_availability';

View file

@ -75,6 +75,8 @@ export const SuggestionSelector = ({
position="top"
content={suggestionsPopoverDisabled ? undefined : activeSuggestion?.title}
anchorProps={{ css: suggestionComboCss }}
display="block"
delay="long"
>
<EuiComboBox
data-test-subj="unifiedHistogramSuggestionSelector"

View file

@ -0,0 +1,190 @@
/*
* 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 { render, act, screen } from '@testing-library/react';
import React from 'react';
import { TimeIntervalSelector } from './time_interval_selector';
describe('TimeIntervalSelector', () => {
it('should render correctly', () => {
const onTimeIntervalChange = jest.fn();
render(
<TimeIntervalSelector
chart={{
timeInterval: 'auto',
}}
onTimeIntervalChange={onTimeIntervalChange}
/>
);
const button = screen.getByTestId('unifiedHistogramTimeIntervalSelectorButton');
expect(button.getAttribute('data-selected-value')).toBe('auto');
act(() => {
button.click();
});
const options = screen.getAllByRole('option');
expect(
options.map((option) => ({
label: option.getAttribute('title'),
value: option.getAttribute('value'),
checked: option.getAttribute('aria-checked'),
}))
).toMatchInlineSnapshot(`
Array [
Object {
"checked": "true",
"label": "Auto",
"value": "auto",
},
Object {
"checked": "false",
"label": "Millisecond",
"value": "ms",
},
Object {
"checked": "false",
"label": "Second",
"value": "s",
},
Object {
"checked": "false",
"label": "Minute",
"value": "m",
},
Object {
"checked": "false",
"label": "Hour",
"value": "h",
},
Object {
"checked": "false",
"label": "Day",
"value": "d",
},
Object {
"checked": "false",
"label": "Week",
"value": "w",
},
Object {
"checked": "false",
"label": "Month",
"value": "M",
},
Object {
"checked": "false",
"label": "Year",
"value": "y",
},
]
`);
});
it('should mark the selected option as checked', () => {
const onTimeIntervalChange = jest.fn();
render(
<TimeIntervalSelector
chart={{
timeInterval: 'y',
}}
onTimeIntervalChange={onTimeIntervalChange}
/>
);
const button = screen.getByTestId('unifiedHistogramTimeIntervalSelectorButton');
expect(button.getAttribute('data-selected-value')).toBe('y');
act(() => {
button.click();
});
const options = screen.getAllByRole('option');
expect(
options.map((option) => ({
label: option.getAttribute('title'),
value: option.getAttribute('value'),
checked: option.getAttribute('aria-checked'),
}))
).toMatchInlineSnapshot(`
Array [
Object {
"checked": "false",
"label": "Auto",
"value": "auto",
},
Object {
"checked": "false",
"label": "Millisecond",
"value": "ms",
},
Object {
"checked": "false",
"label": "Second",
"value": "s",
},
Object {
"checked": "false",
"label": "Minute",
"value": "m",
},
Object {
"checked": "false",
"label": "Hour",
"value": "h",
},
Object {
"checked": "false",
"label": "Day",
"value": "d",
},
Object {
"checked": "false",
"label": "Week",
"value": "w",
},
Object {
"checked": "false",
"label": "Month",
"value": "M",
},
Object {
"checked": "true",
"label": "Year",
"value": "y",
},
]
`);
});
it('should call onTimeIntervalChange with the selected option when the user selects an interval', () => {
const onTimeIntervalChange = jest.fn();
render(
<TimeIntervalSelector
chart={{
timeInterval: 'auto',
}}
onTimeIntervalChange={onTimeIntervalChange}
/>
);
act(() => {
screen.getByTestId('unifiedHistogramTimeIntervalSelectorButton').click();
});
act(() => {
screen.getByTitle('Week').click();
});
expect(onTimeIntervalChange).toHaveBeenCalledWith('w');
});
});

View file

@ -0,0 +1,81 @@
/*
* 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, { useCallback } from 'react';
import { EuiSelectableOption } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { search } from '@kbn/data-plugin/public';
import type { UnifiedHistogramChartContext } from '../types';
import { ToolbarSelector, ToolbarSelectorProps, SelectableEntry } from './toolbar_selector';
export interface TimeIntervalSelectorProps {
chart: UnifiedHistogramChartContext;
onTimeIntervalChange: (timeInterval: string) => void;
}
export const TimeIntervalSelector: React.FC<TimeIntervalSelectorProps> = ({
chart,
onTimeIntervalChange,
}) => {
const onChange: ToolbarSelectorProps['onChange'] = useCallback(
(chosenOption) => {
const selectedOption = chosenOption?.value;
if (selectedOption) {
onTimeIntervalChange(selectedOption);
}
},
[onTimeIntervalChange]
);
const selectedOptionIdx = search.aggs.intervalOptions.findIndex(
(opt) => opt.val === chart.timeInterval
);
const intervalDisplay =
selectedOptionIdx > -1
? search.aggs.intervalOptions[selectedOptionIdx].display
: search.aggs.intervalOptions[0].display;
const options: SelectableEntry[] = search.aggs.intervalOptions
.filter(({ val }) => val !== 'custom')
.map(({ display, val }) => {
return {
key: val,
value: val,
label: display,
checked: val === chart.timeInterval ? ('on' as EuiSelectableOption['checked']) : undefined,
};
});
return (
<ToolbarSelector
data-test-subj="unifiedHistogramTimeIntervalSelector"
data-selected-value={chart.timeInterval}
searchable={false}
buttonLabel={
chart.timeInterval !== 'auto'
? i18n.translate('unifiedHistogram.timeIntervalSelector.buttonLabel', {
defaultMessage: `Interval: {timeInterval}`,
values: {
timeInterval: intervalDisplay.toLowerCase(),
},
})
: i18n.translate('unifiedHistogram.timeIntervalSelector.autoIntervalButtonLabel', {
defaultMessage: 'Auto interval',
})
}
popoverTitle={i18n.translate(
'unifiedHistogram.timeIntervalSelector.timeIntervalPopoverTitle',
{
defaultMessage: 'Select time interval',
}
)}
options={options}
onChange={onChange}
/>
);
};

View file

@ -0,0 +1,178 @@
/*
* 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, { useCallback, ReactElement, useState, useMemo } from 'react';
import {
EuiPopover,
EuiPopoverTitle,
EuiSelectable,
EuiSelectableProps,
EuiSelectableOption,
useEuiTheme,
EuiPanel,
EuiToolTip,
} from '@elastic/eui';
import { ToolbarButton } from '@kbn/shared-ux-button-toolbar';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { calculateWidthFromEntries } from '@kbn/calculate-width-from-char-count';
import { i18n } from '@kbn/i18n';
export const EMPTY_OPTION = '__EMPTY_SELECTOR_OPTION__';
export type SelectableEntry = EuiSelectableOption<{ value: string }>;
export interface ToolbarSelectorProps {
'data-test-subj': string;
'data-selected-value'?: string; // currently selected value
buttonLabel: ReactElement | string;
popoverTitle: string;
options: SelectableEntry[];
searchable: boolean;
onChange?: (chosenOption: SelectableEntry | undefined) => void;
}
export const ToolbarSelector: React.FC<ToolbarSelectorProps> = ({
'data-test-subj': dataTestSubj,
'data-selected-value': dataSelectedValue,
buttonLabel,
popoverTitle,
options,
searchable,
onChange,
}) => {
const { euiTheme } = useEuiTheme();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState<string>();
const [labelPopoverDisabled, setLabelPopoverDisabled] = useState(false);
const disableLabelPopover = useCallback(() => setLabelPopoverDisabled(true), []);
const enableLabelPopover = useCallback(
() => setTimeout(() => setLabelPopoverDisabled(false)),
[]
);
const onSelectionChange = useCallback(
(newOptions) => {
const chosenOption = newOptions.find(({ checked }: SelectableEntry) => checked === 'on');
onChange?.(
chosenOption?.value && chosenOption?.value !== EMPTY_OPTION ? chosenOption : undefined
);
setIsOpen(false);
disableLabelPopover();
},
[disableLabelPopover, onChange]
);
const searchProps: EuiSelectableProps['searchProps'] = useMemo(
() =>
searchable
? {
id: `${dataTestSubj}SelectableInput`,
'data-test-subj': `${dataTestSubj}SelectorSearch`,
compressed: true,
placeholder: i18n.translate(
'unifiedHistogram.toolbarSelectorPopover.searchPlaceholder',
{
defaultMessage: 'Search',
}
),
onChange: (value) => setSearchTerm(value),
}
: undefined,
[dataTestSubj, searchable, setSearchTerm]
);
const panelMinWidth = calculateWidthFromEntries(options, ['label']) + 2 * euiTheme.base; // plus extra width for the right Enter button
return (
<EuiPopover
id={dataTestSubj}
ownFocus
initialFocus={
searchable ? `#${dataTestSubj}SelectableInput` : `#${dataTestSubj}Selectable_listbox`
}
panelProps={{
css: searchable
? css`
min-width: ${panelMinWidth}px;
`
: css`
width: ${panelMinWidth}px;
`,
}}
panelPaddingSize="none"
button={
<EuiToolTip content={labelPopoverDisabled ? undefined : buttonLabel} delay="long">
<ToolbarButton
size="s"
css={css`
font-weight: ${euiTheme.font.weight.medium};
width: 100%;
min-width: 0;
max-width: ${euiTheme.base * 20}px;
`}
data-test-subj={`${dataTestSubj}Button`}
data-selected-value={dataSelectedValue}
aria-label={popoverTitle}
label={buttonLabel}
onClick={() => setIsOpen(!isOpen)}
onBlur={enableLabelPopover}
/>
</EuiToolTip>
}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
anchorPosition="downLeft"
>
<EuiPopoverTitle paddingSize="s">{popoverTitle}</EuiPopoverTitle>
<EuiSelectable
id={`${dataTestSubj}Selectable`}
singleSelection
aria-label={popoverTitle}
data-test-subj={`${dataTestSubj}Selectable`}
options={options}
onChange={onSelectionChange}
listProps={{
truncationProps: { truncation: 'middle' },
isVirtualized: searchable,
}}
{...(searchable
? {
searchable,
searchProps,
noMatchesMessage: (
<p>
<FormattedMessage
id="unifiedHistogram.toolbarSelectorPopover.noResults"
defaultMessage="No results found for {term}"
values={{
term: <strong>{searchTerm}</strong>,
}}
/>
</p>
),
}
: {})}
>
{(list, search) => (
<>
{search && (
<EuiPanel paddingSize="s" hasShadow={false} css={{ paddingBottom: 0 }}>
{search}
</EuiPanel>
)}
{list}
</>
)}
</EuiSelectable>
</EuiPopover>
);
};

View file

@ -54,7 +54,7 @@ export type UnifiedHistogramContainerProps = {
| 'relativeTimeRange'
| 'columns'
| 'container'
| 'appendHitsCounter'
| 'renderCustomChartToggleActions'
| 'children'
| 'onBrushEnd'
| 'onFilter'

View file

@ -211,28 +211,4 @@ describe('UnifiedHistogramStateService', () => {
expect(setTopPanelHeight as jest.Mock).not.toHaveBeenCalled();
expect(setBreakdownField as jest.Mock).not.toHaveBeenCalled();
});
it('should not update total hits to loading when the current status is partial', () => {
const stateService = createStateService({
services: unifiedHistogramServicesMock,
initialState: {
...initialState,
totalHitsStatus: UnifiedHistogramFetchStatus.partial,
},
});
let state: UnifiedHistogramState | undefined;
stateService.state$.subscribe((s) => (state = s));
expect(state).toEqual({
...initialState,
totalHitsStatus: UnifiedHistogramFetchStatus.partial,
});
stateService.setTotalHits({
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
totalHitsResult: 100,
});
expect(state).toEqual({
...initialState,
totalHitsStatus: UnifiedHistogramFetchStatus.partial,
});
});
});

View file

@ -217,15 +217,6 @@ export const createStateService = (
totalHitsStatus: UnifiedHistogramFetchStatus;
totalHitsResult: number | Error | undefined;
}) => {
// If we have a partial result already, we don't
// want to update the total hits back to loading
if (
state$.getValue().totalHitsStatus === UnifiedHistogramFetchStatus.partial &&
totalHits.totalHitsStatus === UnifiedHistogramFetchStatus.loading
) {
return;
}
updateState(totalHits);
},
};

View file

@ -1,72 +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 type { ReactWrapper } from 'enzyme';
import type { HitsCounterProps } from './hits_counter';
import { HitsCounter } from './hits_counter';
import { findTestSubject } from '@elastic/eui/lib/test';
import { EuiLoadingSpinner } from '@elastic/eui';
import { UnifiedHistogramFetchStatus } from '../types';
describe('hits counter', function () {
let props: HitsCounterProps;
let component: ReactWrapper<HitsCounterProps>;
beforeAll(() => {
props = {
hits: {
status: UnifiedHistogramFetchStatus.complete,
total: 2,
},
};
});
it('expect to render the number of hits', function () {
component = mountWithIntl(<HitsCounter {...props} />);
const hits = findTestSubject(component, 'unifiedHistogramQueryHits');
expect(hits.text()).toBe('2');
});
it('expect to render 1,899 hits if 1899 hits given', function () {
component = mountWithIntl(
<HitsCounter
{...props}
hits={{ status: UnifiedHistogramFetchStatus.complete, total: 1899 }}
/>
);
const hits = findTestSubject(component, 'unifiedHistogramQueryHits');
expect(hits.text()).toBe('1,899');
});
it('should render the element passed to the append prop', () => {
const appendHitsCounter = <div data-test-subj="appendHitsCounter">appendHitsCounter</div>;
component = mountWithIntl(<HitsCounter {...props} append={appendHitsCounter} />);
expect(findTestSubject(component, 'appendHitsCounter').length).toBe(1);
});
it('should render a EuiLoadingSpinner when status is partial', () => {
component = mountWithIntl(
<HitsCounter {...props} hits={{ status: UnifiedHistogramFetchStatus.partial, total: 2 }} />
);
expect(component.find(EuiLoadingSpinner).length).toBe(1);
});
it('should render unifiedHistogramQueryHitsPartial when status is partial', () => {
component = mountWithIntl(
<HitsCounter {...props} hits={{ status: UnifiedHistogramFetchStatus.partial, total: 2 }} />
);
expect(component.find('[data-test-subj="unifiedHistogramQueryHitsPartial"]').length).toBe(1);
});
it('should render unifiedHistogramQueryHits when status is complete', () => {
component = mountWithIntl(<HitsCounter {...props} />);
expect(component.find('[data-test-subj="unifiedHistogramQueryHits"]').length).toBe(1);
});
});

View file

@ -1,83 +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 type { ReactElement } from 'react';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiLoadingSpinner } from '@elastic/eui';
import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import type { UnifiedHistogramHitsContext } from '../types';
export interface HitsCounterProps {
hits: UnifiedHistogramHitsContext;
append?: ReactElement;
}
export function HitsCounter({ hits, append }: HitsCounterProps) {
if (!hits.total && hits.status === 'loading') {
return null;
}
const formattedHits = (
<strong
data-test-subj={
hits.status === 'partial' ? 'unifiedHistogramQueryHitsPartial' : 'unifiedHistogramQueryHits'
}
>
<FormattedNumber value={hits.total ?? 0} />
</strong>
);
const hitsCounterCss = css`
flex-grow: 0;
`;
const hitsCounterTextCss = css`
overflow: hidden;
`;
return (
<EuiFlexGroup
gutterSize="s"
responsive={false}
justifyContent="center"
alignItems="center"
css={hitsCounterCss}
>
<EuiFlexItem grow={false} aria-live="polite" css={hitsCounterTextCss}>
<EuiText className="eui-textTruncate">
{hits.status === 'partial' && (
<FormattedMessage
id="unifiedHistogram.partialHits"
defaultMessage="≥{formattedHits} {hits, plural, one {hit} other {hits}}"
values={{ hits: hits.total, formattedHits }}
/>
)}
{hits.status !== 'partial' && (
<FormattedMessage
id="unifiedHistogram.hitsPluralTitle"
defaultMessage="{formattedHits} {hits, plural, one {hit} other {hits}}"
values={{ hits: hits.total, formattedHits }}
/>
)}
</EuiText>
</EuiFlexItem>
{hits.status === 'partial' && (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner
size="m"
aria-label={i18n.translate('unifiedHistogram.hitCountSpinnerAriaLabel', {
defaultMessage: 'Final hit count still loading',
})}
/>
</EuiFlexItem>
)}
{append}
</EuiFlexGroup>
);
}

View file

@ -10,7 +10,6 @@ import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_
import { mountWithIntl } from '@kbn/test-jest-helpers';
import type { ReactWrapper } from 'enzyme';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { of } from 'rxjs';
import { Chart } from '../chart';
import {
@ -153,13 +152,6 @@ describe('Layout', () => {
height: `${expectedHeight}px`,
});
});
it('should pass undefined for onResetChartHeight to Chart when layout mode is ResizableLayoutMode.Static', async () => {
const component = await mountComponent({ topPanelHeight: 123 });
expect(component.find(Chart).prop('onResetChartHeight')).toBeDefined();
setBreakpoint(component, 's');
expect(component.find(Chart).prop('onResetChartHeight')).toBeUndefined();
});
});
describe('topPanelHeight', () => {
@ -167,39 +159,5 @@ describe('Layout', () => {
const component = await mountComponent({ topPanelHeight: undefined });
expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBeGreaterThan(0);
});
it('should reset the fixedPanelSize to the default when onResetChartHeight is called on Chart', async () => {
const component: ReactWrapper = await mountComponent({
onTopPanelHeightChange: jest.fn((topPanelHeight) => {
component.setProps({ topPanelHeight });
}),
});
const defaultTopPanelHeight = component.find(ResizableLayout).prop('fixedPanelSize');
const newTopPanelHeight = 123;
expect(component.find(ResizableLayout).prop('fixedPanelSize')).not.toBe(newTopPanelHeight);
act(() => {
component.find(ResizableLayout).prop('onFixedPanelSizeChange')!(newTopPanelHeight);
});
expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBe(newTopPanelHeight);
act(() => {
component.find(Chart).prop('onResetChartHeight')!();
});
expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBe(defaultTopPanelHeight);
});
it('should pass undefined for onResetChartHeight to Chart when the chart is the default height', async () => {
const component = await mountComponent({
topPanelHeight: 123,
onTopPanelHeightChange: jest.fn((topPanelHeight) => {
component.setProps({ topPanelHeight });
}),
});
expect(component.find(Chart).prop('onResetChartHeight')).toBeDefined();
act(() => {
component.find(Chart).prop('onResetChartHeight')!();
});
component.update();
expect(component.find(Chart).prop('onResetChartHeight')).toBeUndefined();
});
});
});

View file

@ -7,7 +7,7 @@
*/
import { EuiSpacer, useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui';
import React, { PropsWithChildren, ReactElement, useMemo, useState } from 'react';
import React, { PropsWithChildren, ReactElement, useState } from 'react';
import { Observable } from 'rxjs';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { css } from '@emotion/css';
@ -26,7 +26,7 @@ import {
ResizableLayoutMode,
ResizableLayoutDirection,
} from '@kbn/resizable-layout';
import { Chart } from '../chart';
import { Chart, checkChartAvailability } from '../chart';
import type {
UnifiedHistogramChartContext,
UnifiedHistogramServices,
@ -39,6 +39,10 @@ import type {
} from '../types';
import { useLensSuggestions } from './hooks/use_lens_suggestions';
const ChartMemoized = React.memo(Chart);
const chartSpacer = <EuiSpacer size="s" />;
export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown> {
/**
* Optional class name to add to the layout container
@ -107,9 +111,9 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown>
*/
topPanelHeight?: number;
/**
* Append a custom element to the right of the hits count
* This element would replace the default chart toggle buttons
*/
appendHitsCounter?: ReactElement;
renderCustomChartToggleActions?: () => ReactElement | undefined;
/**
* Disable automatic refetching based on props changes, and instead wait for a `refetch` message
*/
@ -197,7 +201,7 @@ export const UnifiedHistogramLayout = ({
breakdown,
container,
topPanelHeight,
appendHitsCounter,
renderCustomChartToggleActions,
disableAutoFetching,
disableTriggers,
disabledActions,
@ -234,6 +238,8 @@ export const UnifiedHistogramLayout = ({
});
const chart = suggestionUnsupported ? undefined : originalChart;
const isChartAvailable = checkChartAvailability({ chart, dataView, isPlainRecord });
const [topPanelNode] = useState(() =>
createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } })
);
@ -263,17 +269,11 @@ export const UnifiedHistogramLayout = ({
const currentTopPanelHeight = topPanelHeight ?? defaultTopPanelHeight;
const onResetChartHeight = useMemo(() => {
return currentTopPanelHeight !== defaultTopPanelHeight &&
panelsMode === ResizableLayoutMode.Resizable
? () => onTopPanelHeightChange?.(undefined)
: undefined;
}, [currentTopPanelHeight, defaultTopPanelHeight, onTopPanelHeightChange, panelsMode]);
return (
<>
<InPortal node={topPanelNode}>
<Chart
<ChartMemoized
isChartAvailable={isChartAvailable}
className={chartClassName}
services={services}
dataView={dataView}
@ -289,13 +289,12 @@ export const UnifiedHistogramLayout = ({
isPlainRecord={isPlainRecord}
chart={chart}
breakdown={breakdown}
appendHitsCounter={appendHitsCounter}
appendHistogram={<EuiSpacer size="s" />}
renderCustomChartToggleActions={renderCustomChartToggleActions}
appendHistogram={chartSpacer}
disableAutoFetching={disableAutoFetching}
disableTriggers={disableTriggers}
disabledActions={disabledActions}
input$={input$}
onResetChartHeight={onResetChartHeight}
onChartHiddenChange={onChartHiddenChange}
onTimeIntervalChange={onTimeIntervalChange}
onBreakdownFieldChange={onBreakdownFieldChange}
@ -311,7 +310,11 @@ export const UnifiedHistogramLayout = ({
withDefaultActions={withDefaultActions}
/>
</InPortal>
<InPortal node={mainPanelNode}>{children}</InPortal>
<InPortal node={mainPanelNode}>
{React.isValidElement(children)
? React.cloneElement(children, { isChartAvailable })
: children}
</InPortal>
<ResizableLayout
className={className}
mode={panelsMode}

View file

@ -13,7 +13,6 @@
"@kbn/inspector-plugin",
"@kbn/expressions-plugin",
"@kbn/test-jest-helpers",
"@kbn/i18n-react",
"@kbn/i18n",
"@kbn/es-query",
"@kbn/embeddable-plugin",
@ -26,7 +25,10 @@
"@kbn/visualizations-plugin",
"@kbn/discover-utils",
"@kbn/resizable-layout",
"@kbn/shared-ux-button-toolbar",
"@kbn/calculate-width-from-char-count",
"@kbn/i18n-react",
"@kbn/field-utils",
],
"exclude": [
"target/**/*",

View file

@ -159,24 +159,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await a11y.testAppSnapshot();
});
it('a11y test for chart options panel', async () => {
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await a11y.testAppSnapshot();
});
it('a11y test for data grid with hidden chart', async () => {
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.closeHistogramPanel();
await a11y.testAppSnapshot();
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.openHistogramPanel();
});
it('a11y test for time interval panel', async () => {
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramTimeIntervalPanel');
await testSubjects.click('unifiedHistogramTimeIntervalSelectorButton');
await a11y.testAppSnapshot();
await testSubjects.click('contextMenuPanelTitleButton');
await testSubjects.click('unifiedHistogramChartOptionsToggle');
});
it('a11y test for data grid sort panel', async () => {
@ -205,7 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('a11y test for data grid with collapsed side bar', async () => {
await PageObjects.discover.closeSidebar();
await a11y.testAppSnapshot();
await PageObjects.discover.toggleSidebarCollapse();
await PageObjects.discover.openSidebar();
});
it('a11y test for adding a field from side bar', async () => {

View file

@ -114,10 +114,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should show correct initial chart interval of Auto', async function () {
await PageObjects.timePicker.setDefaultAbsoluteRange();
await PageObjects.discover.waitUntilSearchingHasFinished();
await testSubjects.click('unifiedHistogramQueryHits'); // to cancel out tooltips
await testSubjects.click('discoverQueryHits'); // to cancel out tooltips
const actualInterval = await PageObjects.discover.getChartInterval();
const expectedInterval = 'Auto';
const expectedInterval = 'auto';
expect(actualInterval).to.be(expectedInterval);
});

View file

@ -156,6 +156,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const chartCanvasExist = await elasticChart.canvasExists();
expect(chartCanvasExist).to.be(true);
const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon();
expect(chartIntervalIconTip).to.be(false);
});
it('should visualize monthly data with different years scaled to seconds', async () => {
const from = 'Jan 1, 2010 @ 00:00:00.000';
const to = 'Mar 21, 2019 @ 00:00:00.000';
await prepareTest({ from, to }, 'Second');
const chartCanvasExist = await elasticChart.canvasExists();
expect(chartCanvasExist).to.be(true);
const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon();
expect(chartIntervalIconTip).to.be(true);
});
it('should allow hide/show histogram, persisted in url state', async () => {
@ -164,8 +173,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await prepareTest({ from, to });
let canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(true);
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(false);
@ -174,8 +182,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await browser.refresh();
canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(false);
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
@ -189,8 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await prepareTest({ from, to });
// close chart for saved search
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
let canvasExists: boolean;
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
@ -212,8 +218,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(canvasExists).to.be(false);
// open chart for saved search
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await retry.waitFor(`Discover histogram to be displayed`, async () => {
canvasExists = await elasticChart.canvasExists();
return canvasExists;
@ -235,8 +240,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('should show permitted hidden histogram state when returning back to discover', async () => {
// close chart
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
let canvasExists: boolean;
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
@ -248,8 +252,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.header.waitUntilLoadingHasFinished();
// open chart
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(true);
@ -266,8 +269,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(canvasExists).to.be(true);
// close chart
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(false);
@ -278,8 +280,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('discover');
await PageObjects.discover.waitUntilSearchingHasFinished();
// Make sure the chart is visible
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await PageObjects.discover.waitUntilSearchingHasFinished();
// type an invalid search query, hit refresh
await queryBar.setQuery('this is > not valid');

View file

@ -71,17 +71,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should hide elements beneath the table when in full screen mode regardless of their z-index', async () => {
await retry.try(async () => {
expect(await isVisible('unifiedHistogramQueryHits')).to.be(true);
expect(await isVisible('discover-dataView-switch-link')).to.be(true);
expect(await isVisible('unifiedHistogramResizableButton')).to.be(true);
});
await testSubjects.click('dataGridFullScreenButton');
await retry.try(async () => {
expect(await isVisible('unifiedHistogramQueryHits')).to.be(false);
expect(await isVisible('discover-dataView-switch-link')).to.be(false);
expect(await isVisible('unifiedHistogramResizableButton')).to.be(false);
});
await testSubjects.click('dataGridFullScreenButton');
await retry.try(async () => {
expect(await isVisible('unifiedHistogramQueryHits')).to.be(true);
expect(await isVisible('discover-dataView-switch-link')).to.be(true);
expect(await isVisible('unifiedHistogramResizableButton')).to.be(true);
});
});

View file

@ -0,0 +1,261 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const testSubjects = getService('testSubjects');
const monacoEditor = getService('monacoEditor');
const PageObjects = getPageObjects([
'settings',
'common',
'discover',
'header',
'timePicker',
'unifiedFieldList',
]);
const security = getService('security');
const defaultSettings = {
defaultIndex: 'logstash-*',
hideAnnouncements: true,
};
describe('discover panels toggle', function () {
before(async () => {
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']);
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover');
});
after(async () => {
await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover');
await kibanaServer.uiSettings.replace({});
await kibanaServer.savedObjects.cleanStandardList();
});
async function checkSidebarAndHistogram({
shouldSidebarBeOpen,
shouldHistogramBeOpen,
isChartAvailable,
totalHits,
}: {
shouldSidebarBeOpen: boolean;
shouldHistogramBeOpen: boolean;
isChartAvailable: boolean;
totalHits: string;
}) {
expect(await PageObjects.discover.getHitCount()).to.be(totalHits);
if (shouldSidebarBeOpen) {
expect(await PageObjects.discover.isSidebarPanelOpen()).to.be(true);
await testSubjects.existOrFail('unifiedFieldListSidebar__toggle-collapse');
await testSubjects.missingOrFail('dscShowSidebarButton');
} else {
expect(await PageObjects.discover.isSidebarPanelOpen()).to.be(false);
await testSubjects.missingOrFail('unifiedFieldListSidebar__toggle-collapse');
await testSubjects.existOrFail('dscShowSidebarButton');
}
if (isChartAvailable) {
expect(await PageObjects.discover.isChartVisible()).to.be(shouldHistogramBeOpen);
if (shouldHistogramBeOpen) {
await testSubjects.existOrFail('dscPanelsToggleInHistogram');
await testSubjects.existOrFail('dscHideHistogramButton');
await testSubjects.missingOrFail('dscPanelsToggleInPage');
await testSubjects.missingOrFail('dscShowHistogramButton');
} else {
await testSubjects.existOrFail('dscPanelsToggleInPage');
await testSubjects.existOrFail('dscShowHistogramButton');
await testSubjects.missingOrFail('dscPanelsToggleInHistogram');
await testSubjects.missingOrFail('dscHideHistogramButton');
}
} else {
expect(await PageObjects.discover.isChartVisible()).to.be(false);
await testSubjects.missingOrFail('dscPanelsToggleInHistogram');
await testSubjects.missingOrFail('dscHideHistogramButton');
await testSubjects.missingOrFail('dscShowHistogramButton');
if (shouldSidebarBeOpen) {
await testSubjects.missingOrFail('dscPanelsToggleInPage');
} else {
await testSubjects.existOrFail('dscPanelsToggleInPage');
}
}
}
function checkPanelsToggle({
isChartAvailable,
totalHits,
}: {
isChartAvailable: boolean;
totalHits: string;
}) {
it('sidebar can be toggled', async () => {
await checkSidebarAndHistogram({
shouldSidebarBeOpen: true,
shouldHistogramBeOpen: true,
isChartAvailable,
totalHits,
});
await PageObjects.discover.closeSidebar();
await checkSidebarAndHistogram({
shouldSidebarBeOpen: false,
shouldHistogramBeOpen: true,
isChartAvailable,
totalHits,
});
await PageObjects.discover.openSidebar();
await checkSidebarAndHistogram({
shouldSidebarBeOpen: true,
shouldHistogramBeOpen: true,
isChartAvailable,
totalHits,
});
});
if (isChartAvailable) {
it('histogram can be toggled', async () => {
await checkSidebarAndHistogram({
shouldSidebarBeOpen: true,
shouldHistogramBeOpen: true,
isChartAvailable,
totalHits,
});
await PageObjects.discover.closeHistogramPanel();
await checkSidebarAndHistogram({
shouldSidebarBeOpen: true,
shouldHistogramBeOpen: false,
isChartAvailable,
totalHits,
});
await PageObjects.discover.openHistogramPanel();
await checkSidebarAndHistogram({
shouldSidebarBeOpen: true,
shouldHistogramBeOpen: true,
isChartAvailable,
totalHits,
});
});
it('sidebar and histogram can be toggled', async () => {
await checkSidebarAndHistogram({
shouldSidebarBeOpen: true,
shouldHistogramBeOpen: true,
isChartAvailable,
totalHits,
});
await PageObjects.discover.closeSidebar();
await PageObjects.discover.closeHistogramPanel();
await checkSidebarAndHistogram({
shouldSidebarBeOpen: false,
shouldHistogramBeOpen: false,
isChartAvailable,
totalHits,
});
await PageObjects.discover.openSidebar();
await PageObjects.discover.openHistogramPanel();
await checkSidebarAndHistogram({
shouldSidebarBeOpen: true,
shouldHistogramBeOpen: true,
isChartAvailable,
totalHits,
});
});
}
}
describe('time based data view', function () {
before(async function () {
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await kibanaServer.uiSettings.update(defaultSettings);
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.waitUntilSearchingHasFinished();
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
});
checkPanelsToggle({ isChartAvailable: true, totalHits: '14,004' });
});
describe('non-time based data view', function () {
before(async function () {
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await kibanaServer.uiSettings.update(defaultSettings);
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.createAdHocDataView('log*', false);
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
});
checkPanelsToggle({ isChartAvailable: false, totalHits: '14,004' });
});
describe('text-based with histogram chart', function () {
before(async function () {
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await kibanaServer.uiSettings.update(defaultSettings);
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.selectTextBaseLang();
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
});
checkPanelsToggle({ isChartAvailable: true, totalHits: '10' });
});
describe('text-based with aggs chart', function () {
before(async function () {
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await kibanaServer.uiSettings.update(defaultSettings);
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.selectTextBaseLang();
await monacoEditor.setCodeEditorValue(
'from logstash-* | stats avg(bytes) by extension | limit 100'
);
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
});
checkPanelsToggle({ isChartAvailable: true, totalHits: '5' });
});
describe('text-based without a time field', function () {
before(async function () {
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await kibanaServer.uiSettings.update(defaultSettings);
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.createAdHocDataView('log*', false);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.selectTextBaseLang();
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
});
checkPanelsToggle({ isChartAvailable: false, totalHits: '10' });
});
});
}

View file

@ -240,7 +240,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
savedSearch: 'esql test',
query1: 'from logstash-* | where bytes > 1000 | stats countB = count(bytes) ',
query2: 'from logstash-* | where bytes < 2000 | stats countB = count(bytes) ',
savedSearchesRequests: 4,
savedSearchesRequests: 3,
setQuery: (query) => monacoEditor.setCodeEditorValue(query),
});
});

View file

@ -273,13 +273,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('should collapse when clicked', async function () {
await PageObjects.discover.toggleSidebarCollapse();
await testSubjects.existOrFail('discover-sidebar');
await PageObjects.discover.closeSidebar();
await testSubjects.existOrFail('dscShowSidebarButton');
await testSubjects.missingOrFail('fieldList');
});
it('should expand when clicked', async function () {
await PageObjects.discover.toggleSidebarCollapse();
await PageObjects.discover.openSidebar();
await testSubjects.existOrFail('discover-sidebar');
await testSubjects.existOrFail('fieldList');
});

View file

@ -27,5 +27,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./_doc_viewer'));
loadTestFile(require.resolve('./_view_mode_toggle'));
loadTestFile(require.resolve('./_unsaved_changes_badge'));
loadTestFile(require.resolve('./_panels_toggle'));
});
}

View file

@ -52,7 +52,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.exists('addFilter')).to.be(true);
expect(await testSubjects.exists('dscViewModeDocumentButton')).to.be(true);
expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true);
expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true);
expect(await testSubjects.exists('discoverQueryHits')).to.be(true);
expect(await testSubjects.exists('discoverAlertsButton')).to.be(true);
expect(await testSubjects.exists('shareTopNavButton')).to.be(true);
expect(await testSubjects.exists('docTableExpandToggleColumn')).to.be(true);
@ -74,7 +74,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.exists('dscViewModeDocumentButton')).to.be(false);
// when Lens suggests a table, we render an ESQL based histogram
expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true);
expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true);
expect(await testSubjects.exists('discoverQueryHits')).to.be(true);
expect(await testSubjects.exists('discoverAlertsButton')).to.be(true);
expect(await testSubjects.exists('shareTopNavButton')).to.be(true);
expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(false);

View file

@ -215,12 +215,26 @@ export class DiscoverPageObject extends FtrService {
);
}
public async chooseBreakdownField(field: string) {
await this.comboBox.set('unifiedHistogramBreakdownFieldSelector', field);
public async chooseBreakdownField(field: string, value?: string) {
await this.retry.try(async () => {
await this.testSubjects.click('unifiedHistogramBreakdownSelectorButton');
await this.testSubjects.existOrFail('unifiedHistogramBreakdownSelectorSelectable');
});
await (
await this.testSubjects.find('unifiedHistogramBreakdownSelectorSelectorSearch')
).type(field);
const option = await this.find.byCssSelector(
`[data-test-subj="unifiedHistogramBreakdownSelectorSelectable"] .euiSelectableListItem[value="${
value ?? field
}"]`
);
await option.click();
}
public async clearBreakdownField() {
await this.comboBox.clear('unifiedHistogramBreakdownFieldSelector');
await this.chooseBreakdownField('No breakdown', '__EMPTY_SELECTOR_OPTION__');
}
public async chooseLensChart(chart: string) {
@ -248,36 +262,52 @@ export class DiscoverPageObject extends FtrService {
}
public async toggleChartVisibility() {
await this.testSubjects.moveMouseTo('unifiedHistogramChartOptionsToggle');
await this.testSubjects.click('unifiedHistogramChartOptionsToggle');
await this.testSubjects.exists('unifiedHistogramChartToggle');
await this.testSubjects.click('unifiedHistogramChartToggle');
if (await this.isChartVisible()) {
await this.testSubjects.click('dscHideHistogramButton');
} else {
await this.testSubjects.click('dscShowHistogramButton');
}
await this.header.waitUntilLoadingHasFinished();
}
public async openHistogramPanel() {
await this.testSubjects.click('dscShowHistogramButton');
await this.header.waitUntilLoadingHasFinished();
}
public async closeHistogramPanel() {
await this.testSubjects.click('dscHideHistogramButton');
await this.header.waitUntilLoadingHasFinished();
}
public async getChartInterval() {
await this.testSubjects.click('unifiedHistogramChartOptionsToggle');
await this.testSubjects.click('unifiedHistogramTimeIntervalPanel');
const selectedOption = await this.find.byCssSelector(`.unifiedHistogramIntervalSelected`);
return selectedOption.getVisibleText();
const button = await this.testSubjects.find('unifiedHistogramTimeIntervalSelectorButton');
return await button.getAttribute('data-selected-value');
}
public async getChartIntervalWarningIcon() {
await this.testSubjects.click('unifiedHistogramChartOptionsToggle');
await this.header.waitUntilLoadingHasFinished();
return await this.find.existsByCssSelector('.euiToolTipAnchor');
return await this.find.existsByCssSelector(
'[data-test-subj="unifiedHistogramRendered"] .euiToolTipAnchor'
);
}
public async setChartInterval(interval: string) {
await this.testSubjects.click('unifiedHistogramChartOptionsToggle');
await this.testSubjects.click('unifiedHistogramTimeIntervalPanel');
await this.testSubjects.click(`unifiedHistogramTimeInterval-${interval}`);
public async setChartInterval(intervalTitle: string) {
await this.retry.try(async () => {
await this.testSubjects.click('unifiedHistogramTimeIntervalSelectorButton');
await this.testSubjects.existOrFail('unifiedHistogramTimeIntervalSelectorSelectable');
});
const option = await this.find.byCssSelector(
`[data-test-subj="unifiedHistogramTimeIntervalSelectorSelectable"] .euiSelectableListItem[title="${intervalTitle}"]`
);
await option.click();
return await this.header.waitUntilLoadingHasFinished();
}
public async getHitCount() {
await this.header.waitUntilLoadingHasFinished();
return await this.testSubjects.getVisibleText('unifiedHistogramQueryHits');
return await this.testSubjects.getVisibleText('discoverQueryHits');
}
public async getHitCountInt() {
@ -398,8 +428,12 @@ export class DiscoverPageObject extends FtrService {
return await Promise.all(marks.map((mark) => mark.getVisibleText()));
}
public async toggleSidebarCollapse() {
return await this.testSubjects.click('unifiedFieldListSidebar__toggle');
public async openSidebar() {
await this.testSubjects.click('dscShowSidebarButton');
await this.retry.waitFor('sidebar to appear', async () => {
return await this.isSidebarPanelOpen();
});
}
public async closeSidebar() {
@ -410,6 +444,13 @@ export class DiscoverPageObject extends FtrService {
});
}
public async isSidebarPanelOpen() {
return (
(await this.testSubjects.exists('fieldList')) &&
(await this.testSubjects.exists('unifiedFieldListSidebar__toggle-collapse'))
);
}
public async editField(field: string) {
await this.retry.try(async () => {
await this.unifiedFieldList.pressEnterFieldListItemToggle(field);

View file

@ -2440,6 +2440,9 @@
"discover.viewAlert.searchSourceErrorTitle": "Erreur lors de la récupération de la source de recherche",
"discover.viewModes.document.label": "Documents",
"discover.viewModes.fieldStatistics.label": "Statistiques de champ",
"discover.hitsCounter.hitsPluralTitle": "{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}",
"discover.hitsCounter.partialHitsPluralTitle": "≥{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}",
"discover.hitsCounter.hitCountSpinnerAriaLabel": "Nombre final de résultats toujours en chargement",
"domDragDrop.announce.cancelled": "Mouvement annulé. {label} revenu à sa position initiale",
"domDragDrop.announce.cancelledItem": "Mouvement annulé. {label} revenu au groupe {groupLabel} à la position {position}",
"domDragDrop.announce.dropped.combineCompatible": "{label} combiné dans le groupe {groupLabel} en {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}",
@ -6186,7 +6189,6 @@
"unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "Champs",
"unifiedFieldList.fieldListSidebar.flyoutBackIcon": "Retour",
"unifiedFieldList.fieldListSidebar.flyoutHeading": "Liste des champs",
"unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "Index et champs",
"unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "Activer/Désactiver la barre latérale",
"unifiedFieldList.fieldNameSearch.filterByNameLabel": "Rechercher les noms de champs",
"unifiedFieldList.fieldPopover.addExistsFilterLabel": "Filtrer sur le champ",
@ -6231,31 +6233,18 @@
"unifiedHistogram.breakdownColumnLabel": "Top 3 des valeurs de {fieldName}",
"unifiedHistogram.bucketIntervalTooltip": "Cet intervalle crée {bucketsDescription} pour un affichage dans la plage temporelle sélectionnée. Il a donc été scalé à {bucketIntervalDescription}.",
"unifiedHistogram.histogramTimeRangeIntervalDescription": "(intervalle : {value})",
"unifiedHistogram.hitsPluralTitle": "{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}",
"unifiedHistogram.partialHits": "≥{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}",
"unifiedHistogram.timeIntervalWithValue": "Intervalle de temps : {timeInterval}",
"unifiedHistogram.breakdownFieldSelectorAriaLabel": "Répartir par",
"unifiedHistogram.breakdownFieldSelectorLabel": "Répartir par",
"unifiedHistogram.breakdownFieldSelectorPlaceholder": "Sélectionner un champ",
"unifiedHistogram.bucketIntervalTooltip.tooLargeBucketsText": "des compartiments trop volumineux",
"unifiedHistogram.bucketIntervalTooltip.tooManyBucketsText": "un trop grand nombre de compartiments",
"unifiedHistogram.chartOptions": "Options de graphique",
"unifiedHistogram.chartOptionsButton": "Options de graphique",
"unifiedHistogram.countColumnLabel": "Nombre d'enregistrements",
"unifiedHistogram.editVisualizationButton": "Modifier la visualisation",
"unifiedHistogram.hideChart": "Masquer le graphique",
"unifiedHistogram.histogramOfFoundDocumentsAriaLabel": "Histogramme des documents détectés",
"unifiedHistogram.histogramTimeRangeIntervalAuto": "Auto",
"unifiedHistogram.histogramTimeRangeIntervalLoading": "Chargement",
"unifiedHistogram.hitCountSpinnerAriaLabel": "Nombre final de résultats toujours en chargement",
"unifiedHistogram.inspectorRequestDataTitleTotalHits": "Nombre total de résultats",
"unifiedHistogram.inspectorRequestDescriptionTotalHits": "Cette requête interroge Elasticsearch afin de récupérer le nombre total de résultats.",
"unifiedHistogram.lensTitle": "Modifier la visualisation",
"unifiedHistogram.resetChartHeight": "Réinitialiser à la hauteur par défaut",
"unifiedHistogram.saveVisualizationButton": "Enregistrer la visualisation",
"unifiedHistogram.showChart": "Afficher le graphique",
"unifiedHistogram.suggestionSelectorPlaceholder": "Sélectionner la visualisation",
"unifiedHistogram.timeIntervals": "Intervalles de temps",
"unifiedHistogram.timeIntervalWithValueWarning": "Avertissement",
"unifiedSearch.filter.filterBar.filterActionsMessage": "Filtrer : {innerText}. Sélectionner pour plus dactions de filtrage.",
"unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel": "Supprimer {filter}",

View file

@ -2454,6 +2454,9 @@
"discover.viewAlert.searchSourceErrorTitle": "検索ソースの取得エラー",
"discover.viewModes.document.label": "ドキュメント",
"discover.viewModes.fieldStatistics.label": "フィールド統計情報",
"discover.hitsCounter.hitsPluralTitle": "{formattedHits} {hits, plural, other {ヒット}}",
"discover.hitsCounter.partialHitsPluralTitle": "≥{formattedHits}{hits, plural, other {ヒット}}",
"discover.hitsCounter.hitCountSpinnerAriaLabel": "読み込み中の最終一致件数",
"domDragDrop.announce.cancelled": "移動がキャンセルされました。{label}は初期位置に戻りました",
"domDragDrop.announce.cancelledItem": "移動がキャンセルされました。{label}は位置{position}の{groupLabel}グループに戻りました",
"domDragDrop.announce.dropped.combineCompatible": "レイヤー{dropLayerNumber}の位置{dropPosition}でグループ{dropGroupLabel}の{dropLabel}にグループ{groupLabel}の{label}を結合しました。",
@ -6201,7 +6204,6 @@
"unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "フィールド",
"unifiedFieldList.fieldListSidebar.flyoutBackIcon": "戻る",
"unifiedFieldList.fieldListSidebar.flyoutHeading": "フィールドリスト",
"unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "インデックスとフィールド",
"unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "サイドバーを切り替える",
"unifiedFieldList.fieldNameSearch.filterByNameLabel": "検索フィールド名",
"unifiedFieldList.fieldPopover.addExistsFilterLabel": "フィールド表示のフィルター",
@ -6246,31 +6248,18 @@
"unifiedHistogram.breakdownColumnLabel": "{fieldName}のトップ3の値",
"unifiedHistogram.bucketIntervalTooltip": "この間隔は選択された時間範囲に表示される{bucketsDescription}を作成するため、{bucketIntervalDescription}にスケーリングされています。",
"unifiedHistogram.histogramTimeRangeIntervalDescription": "(間隔:{value}",
"unifiedHistogram.hitsPluralTitle": "{formattedHits} {hits, plural, other {ヒット}}",
"unifiedHistogram.partialHits": "≥{formattedHits}{hits, plural, other {ヒット}}",
"unifiedHistogram.timeIntervalWithValue": "時間間隔:{timeInterval}",
"unifiedHistogram.breakdownFieldSelectorAriaLabel": "内訳の基準",
"unifiedHistogram.breakdownFieldSelectorLabel": "内訳の基準",
"unifiedHistogram.breakdownFieldSelectorPlaceholder": "フィールドを選択",
"unifiedHistogram.bucketIntervalTooltip.tooLargeBucketsText": "大きすぎるバケット",
"unifiedHistogram.bucketIntervalTooltip.tooManyBucketsText": "バケットが多すぎます",
"unifiedHistogram.chartOptions": "グラフオプション",
"unifiedHistogram.chartOptionsButton": "グラフオプション",
"unifiedHistogram.countColumnLabel": "レコード数",
"unifiedHistogram.editVisualizationButton": "ビジュアライゼーションを編集",
"unifiedHistogram.hideChart": "グラフを非表示",
"unifiedHistogram.histogramOfFoundDocumentsAriaLabel": "検出されたドキュメントのヒストグラム",
"unifiedHistogram.histogramTimeRangeIntervalAuto": "自動",
"unifiedHistogram.histogramTimeRangeIntervalLoading": "読み込み中",
"unifiedHistogram.hitCountSpinnerAriaLabel": "読み込み中の最終一致件数",
"unifiedHistogram.inspectorRequestDataTitleTotalHits": "総ヒット数",
"unifiedHistogram.inspectorRequestDescriptionTotalHits": "このリクエストはElasticsearchにクエリをかけ、合計一致数を取得します。",
"unifiedHistogram.lensTitle": "ビジュアライゼーションを編集",
"unifiedHistogram.resetChartHeight": "デフォルトの高さにリセット",
"unifiedHistogram.saveVisualizationButton": "ビジュアライゼーションを保存",
"unifiedHistogram.showChart": "グラフを表示",
"unifiedHistogram.suggestionSelectorPlaceholder": "ビジュアライゼーションを選択",
"unifiedHistogram.timeIntervals": "時間間隔",
"unifiedHistogram.timeIntervalWithValueWarning": "警告",
"unifiedSearch.filter.filterBar.filterActionsMessage": "フィルター:{innerText}。他のフィルターアクションを使用するには選択してください。",
"unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel": "{filter}削除",

View file

@ -2454,6 +2454,9 @@
"discover.viewAlert.searchSourceErrorTitle": "提取搜索源时出错",
"discover.viewModes.document.label": "文档",
"discover.viewModes.fieldStatistics.label": "字段统计信息",
"discover.hitsCounter.hitsPluralTitle": "{formattedHits} {hits, plural, other {命中数}}",
"discover.hitsCounter.partialHitsPluralTitle": "≥{formattedHits} {hits, plural, other {命中数}}",
"discover.hitsCounter.hitCountSpinnerAriaLabel": "最终命中计数仍在加载",
"domDragDrop.announce.cancelled": "移动已取消。{label} 已返回至其初始位置",
"domDragDrop.announce.cancelledItem": "移动已取消。{label} 返回至 {groupLabel} 组中的位置 {position}",
"domDragDrop.announce.dropped.combineCompatible": "已将组 {groupLabel} 中的 {label} 组合到图层 {dropLayerNumber} 的组 {dropGroupLabel} 中的位置 {dropPosition} 上的 {dropLabel}",
@ -6294,7 +6297,6 @@
"unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "字段",
"unifiedFieldList.fieldListSidebar.flyoutBackIcon": "返回",
"unifiedFieldList.fieldListSidebar.flyoutHeading": "字段列表",
"unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "索引和字段",
"unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "切换侧边栏",
"unifiedFieldList.fieldNameSearch.filterByNameLabel": "搜索字段名称",
"unifiedFieldList.fieldPopover.addExistsFilterLabel": "筛留存在的字段",
@ -6339,31 +6341,18 @@
"unifiedHistogram.breakdownColumnLabel": "{fieldName} 的排名前 3 值",
"unifiedHistogram.bucketIntervalTooltip": "此时间间隔创建的{bucketsDescription}无法在选定时间范围内显示,因此已调整为 {bucketIntervalDescription}。",
"unifiedHistogram.histogramTimeRangeIntervalDescription": "(时间间隔:{value}",
"unifiedHistogram.hitsPluralTitle": "{formattedHits} {hits, plural, other {命中数}}",
"unifiedHistogram.partialHits": "≥{formattedHits} {hits, plural, other {命中数}}",
"unifiedHistogram.timeIntervalWithValue": "时间间隔:{timeInterval}",
"unifiedHistogram.breakdownFieldSelectorAriaLabel": "细分方式",
"unifiedHistogram.breakdownFieldSelectorLabel": "细分方式",
"unifiedHistogram.breakdownFieldSelectorPlaceholder": "选择字段",
"unifiedHistogram.bucketIntervalTooltip.tooLargeBucketsText": "存储桶过大",
"unifiedHistogram.bucketIntervalTooltip.tooManyBucketsText": "存储桶过多",
"unifiedHistogram.chartOptions": "图表选项",
"unifiedHistogram.chartOptionsButton": "图表选项",
"unifiedHistogram.countColumnLabel": "记录计数",
"unifiedHistogram.editVisualizationButton": "编辑可视化",
"unifiedHistogram.hideChart": "隐藏图表",
"unifiedHistogram.histogramOfFoundDocumentsAriaLabel": "已找到文档的直方图",
"unifiedHistogram.histogramTimeRangeIntervalAuto": "自动",
"unifiedHistogram.histogramTimeRangeIntervalLoading": "正在加载",
"unifiedHistogram.hitCountSpinnerAriaLabel": "最终命中计数仍在加载",
"unifiedHistogram.inspectorRequestDataTitleTotalHits": "总命中数",
"unifiedHistogram.inspectorRequestDescriptionTotalHits": "此请求将查询 Elasticsearch 以获取总命中数。",
"unifiedHistogram.lensTitle": "编辑可视化",
"unifiedHistogram.resetChartHeight": "重置为默认高度",
"unifiedHistogram.saveVisualizationButton": "保存可视化",
"unifiedHistogram.showChart": "显示图表",
"unifiedHistogram.suggestionSelectorPlaceholder": "选择可视化",
"unifiedHistogram.timeIntervals": "时间间隔",
"unifiedHistogram.timeIntervalWithValueWarning": "警告",
"unifiedSearch.filter.filterBar.filterActionsMessage": "筛选:{innerText}。选择以获取更多筛选操作。",
"unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel": "删除 {filter}",

View file

@ -69,7 +69,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
).getVisibleText();
expect(actualIndexPattern).to.be('*stash*');
const actualDiscoverQueryHits = await testSubjects.getVisibleText('unifiedHistogramQueryHits');
const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits');
expect(actualDiscoverQueryHits).to.be('14,005');
expect(await PageObjects.unifiedSearch.isAdHocDataView()).to.be(true);
};
@ -208,9 +208,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
).getVisibleText();
expect(actualIndexPattern).to.be('*stash*');
const actualDiscoverQueryHits = await testSubjects.getVisibleText(
'unifiedHistogramQueryHits'
);
const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits');
expect(actualDiscoverQueryHits).to.be('14,005');
const prevDataViewId = await PageObjects.discover.getCurrentDataViewId();

View file

@ -133,13 +133,13 @@ export function LogPatternAnalysisPageProvider({ getService, getPageObject }: Ft
async assertDiscoverDocCountExists() {
await retry.tryForTime(30 * 1000, async () => {
await testSubjects.existOrFail('unifiedHistogramQueryHits');
await testSubjects.existOrFail('discoverQueryHits');
});
},
async assertDiscoverDocCount(expectedDocCount: number) {
await retry.tryForTime(5000, async () => {
const docCount = await testSubjects.getVisibleText('unifiedHistogramQueryHits');
const docCount = await testSubjects.getVisibleText('discoverQueryHits');
const formattedDocCount = docCount.replaceAll(',', '');
expect(formattedDocCount).to.eql(
expectedDocCount,

View file

@ -14,11 +14,9 @@ export function TransformDiscoverProvider({ getService }: FtrProviderContext) {
return {
async assertDiscoverQueryHits(expectedDiscoverQueryHits: string) {
await testSubjects.existOrFail('unifiedHistogramQueryHits');
await testSubjects.existOrFail('discoverQueryHits');
const actualDiscoverQueryHits = await testSubjects.getVisibleText(
'unifiedHistogramQueryHits'
);
const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits');
expect(actualDiscoverQueryHits).to.eql(
expectedDiscoverQueryHits,
@ -27,18 +25,16 @@ export function TransformDiscoverProvider({ getService }: FtrProviderContext) {
},
async assertDiscoverQueryHitsMoreThanZero() {
await testSubjects.existOrFail('unifiedHistogramQueryHits');
await testSubjects.existOrFail('discoverQueryHits');
const actualDiscoverQueryHits = await testSubjects.getVisibleText(
'unifiedHistogramQueryHits'
);
const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits');
const hits = parseInt(actualDiscoverQueryHits, 10);
expect(hits).to.greaterThan(0, `Discover query hits should be more than 0, got ${hits}`);
},
async assertNoResults(expectedDestinationIndex: string) {
await testSubjects.missingOrFail('unifiedHistogramQueryHits');
await testSubjects.missingOrFail('discoverQueryHits');
// Discover should use the destination index pattern
const actualIndexPatternSwitchLinkText = await (

View file

@ -43,7 +43,7 @@ export const DISCOVER_FILTER_BADGES = `${DISCOVER_CONTAINER} ${getDataTestSubjec
'filter-badge-'
)}`;
export const DISCOVER_RESULT_HITS = getDataTestSubjectSelector('unifiedHistogramQueryHits');
export const DISCOVER_RESULT_HITS = getDataTestSubjectSelector('discoverQueryHits');
export const DISCOVER_FIELDS_LOADING = getDataTestSubjectSelector(
'fieldListGroupedAvailableFields-countLoading'

View file

@ -117,10 +117,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should show correct initial chart interval of Auto', async function () {
await PageObjects.timePicker.setDefaultAbsoluteRange();
await PageObjects.discover.waitUntilSearchingHasFinished();
await testSubjects.click('unifiedHistogramQueryHits'); // to cancel out tooltips
await testSubjects.click('discoverQueryHits'); // to cancel out tooltips
const actualInterval = await PageObjects.discover.getChartInterval();
const expectedInterval = 'Auto';
const expectedInterval = 'auto';
expect(actualInterval).to.be(expectedInterval);
});

View file

@ -159,6 +159,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const chartCanvasExist = await elasticChart.canvasExists();
expect(chartCanvasExist).to.be(true);
const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon();
expect(chartIntervalIconTip).to.be(false);
});
it('should visualize monthly data with different years scaled to seconds', async () => {
const from = 'Jan 1, 2010 @ 00:00:00.000';
const to = 'Mar 21, 2019 @ 00:00:00.000';
await prepareTest({ from, to }, 'Second');
const chartCanvasExist = await elasticChart.canvasExists();
expect(chartCanvasExist).to.be(true);
const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon();
expect(chartIntervalIconTip).to.be(true);
});
it('should allow hide/show histogram, persisted in url state', async () => {
@ -167,8 +176,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await prepareTest({ from, to });
let canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(true);
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(false);
@ -177,8 +185,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await browser.refresh();
canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(false);
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
@ -192,8 +199,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await prepareTest({ from, to });
// close chart for saved search
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
let canvasExists: boolean;
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
@ -215,8 +221,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(canvasExists).to.be(false);
// open chart for saved search
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await retry.waitFor(`Discover histogram to be displayed`, async () => {
canvasExists = await elasticChart.canvasExists();
return canvasExists;
@ -238,8 +243,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('should show permitted hidden histogram state when returning back to discover', async () => {
// close chart
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
let canvasExists: boolean;
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
@ -251,8 +255,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.header.waitUntilLoadingHasFinished();
// open chart
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(true);
@ -269,8 +272,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(canvasExists).to.be(true);
// close chart
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await retry.try(async () => {
canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(false);
@ -281,8 +283,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('discover');
await PageObjects.discover.waitUntilSearchingHasFinished();
// Make sure the chart is visible
await testSubjects.click('unifiedHistogramChartOptionsToggle');
await testSubjects.click('unifiedHistogramChartToggle');
await PageObjects.discover.toggleChartVisibility();
await PageObjects.discover.waitUntilSearchingHasFinished();
// type an invalid search query, hit refresh
await queryBar.setQuery('this is > not valid');

View file

@ -242,13 +242,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('should collapse when clicked', async function () {
await PageObjects.discover.toggleSidebarCollapse();
await testSubjects.existOrFail('discover-sidebar');
await PageObjects.discover.closeSidebar();
await testSubjects.existOrFail('dscShowSidebarButton');
await testSubjects.missingOrFail('fieldList');
});
it('should expand when clicked', async function () {
await PageObjects.discover.toggleSidebarCollapse();
await PageObjects.discover.openSidebar();
await testSubjects.existOrFail('discover-sidebar');
await testSubjects.existOrFail('fieldList');
});