[Discover] Update layout for unified histogram (#139446)

* [Discover] Replace view mode toggle group with tabs

* [Discover] Clean up new Discover tabs

* [Discover] Refactor layout to have resizable sections

* [Discover] Getting histogram resizing to work

* [Discover] Set panel sizes on load

* [Discover] Create discover_main_content component

* [Discover] Improve layout resizing so chart stays fixed when window is resized

* [Discover] Clean up Discover layout resize code, and implement auto resizing functionality to handle window resizing edge cases

* [Discover] Improving mobile support

* [Discover] Simplify histogram layout

* [Discover] Fix field stats layout

* [Discover] Comment flexbox CSS fix

* [Discover] Refactor discover_main_content to include a fixed panels layout and a resizable panels layout, and switch to fixed panels when in mobile

* [Discover] Fix Discover layout performance issues when resizing to and from mobile

* [Discover] Refactor reverse portals usage to clean things up

* [Discover] Rename Discover panel tsx files

* [Discover] Rollback unnecessary css change

* [Discover] Fix component names for Discover layout

* [Discover] Fix broken discover_layout Jest test

* [Discover] Decoupled discover_panels from discover_main_content to improve testability and reusability

* [Discover] Clean up discover panels for testing

* [Discover] Add Discover panels Jest tests

* [Discover] Clean up Jest tests

* [Discover] Add functional test for resizable layout panels

* [Discover] Fix broken discover_layout Jest tests

* [Discover] Removing unnecessary CSS in discover_panels_fixed.tsx

* [Discover] Fix issue where resizable panels with extra whitespace are shown when data view is not time based, and fix a flexbox issue with fixed panels that caused content to overflow the container

* [Discover] Change Discover view mode tabs to use smaller font, and force blur the Discover layout resize button after a resize

* [Discover] Fix data-test-subj casing for resizable layout work

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Davis McPhee 2022-09-19 09:55:50 -03:00 committed by GitHub
parent ad0a7ac18d
commit 56680ab18c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1271 additions and 168 deletions

View file

@ -5,7 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import React, { memo, ReactElement, useCallback, useEffect, useRef, useState } from 'react';
import moment from 'moment';
import {
EuiButtonIcon,
@ -14,7 +14,6 @@ import {
EuiFlexItem,
EuiPopover,
EuiToolTip,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { DataView } from '@kbn/data-views-plugin/public';
@ -24,8 +23,6 @@ import { GetStateReturn } from '../../services/discover_state';
import { DiscoverHistogram } from './histogram';
import { DataCharts$, DataTotalHits$ } from '../../hooks/use_saved_search';
import { useChartPanels } from './use_chart_panels';
import { VIEW_MODE, DocumentViewModeToggle } from '../../../../components/view_mode_toggle';
import { SHOW_FIELD_STATISTICS } from '../../../../../common';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import {
getVisualizeInformation,
@ -36,33 +33,32 @@ const DiscoverHistogramMemoized = memo(DiscoverHistogram);
export const CHART_HIDDEN_KEY = 'discover:chartHidden';
export function DiscoverChart({
className,
resetSavedSearch,
savedSearch,
savedSearchDataChart$,
savedSearchDataTotalHits$,
stateContainer,
dataView,
viewMode,
setDiscoverViewMode,
hideChart,
interval,
isTimeBased,
appendHistogram,
}: {
className?: string;
resetSavedSearch: () => void;
savedSearch: SavedSearch;
savedSearchDataChart$: DataCharts$;
savedSearchDataTotalHits$: DataTotalHits$;
stateContainer: GetStateReturn;
dataView: DataView;
viewMode: VIEW_MODE;
setDiscoverViewMode: (viewMode: VIEW_MODE) => void;
isTimeBased: boolean;
hideChart?: boolean;
interval?: string;
appendHistogram?: ReactElement;
}) {
const { uiSettings, data, storage } = useDiscoverServices();
const { data, storage } = useDiscoverServices();
const [showChartOptionsPopover, setShowChartOptionsPopover] = useState(false);
const showViewModeToggle = uiSettings.get(SHOW_FIELD_STATISTICS) ?? false;
const chartRef = useRef<{ element: HTMLElement | null; moveFocus: boolean }>({
element: null,
@ -126,9 +122,15 @@ export function DiscoverChart({
);
return (
<EuiFlexGroup direction="column" alignItems="stretch" gutterSize="none" responsive={false}>
<EuiFlexGroup
className={className}
direction="column"
alignItems="stretch"
gutterSize="none"
responsive={false}
>
<EuiFlexItem grow={false} className="dscResultCount">
<EuiFlexGroup justifyContent="spaceBetween" responsive={false}>
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="none" responsive={false}>
<EuiFlexItem
grow={false}
className="dscResultCount__title eui-textTruncate eui-textNoWrap"
@ -139,14 +141,6 @@ export function DiscoverChart({
onResetQuery={resetSavedSearch}
/>
</EuiFlexItem>
{showViewModeToggle && (
<EuiFlexItem grow={false}>
<DocumentViewModeToggle
viewMode={viewMode}
setDiscoverViewMode={setDiscoverViewMode}
/>
</EuiFlexItem>
)}
{isTimeBased && (
<EuiFlexItem className="dscResultCount__toggle" grow={false}>
<EuiFlexGroup direction="row" gutterSize="s" responsive={false}>
@ -203,7 +197,7 @@ export function DiscoverChart({
</EuiFlexGroup>
</EuiFlexItem>
{isTimeBased && !hideChart && (
<EuiFlexItem grow={false}>
<EuiFlexItem>
<section
ref={(element) => (chartRef.current.element = element)}
tabIndex={-1}
@ -218,7 +212,7 @@ export function DiscoverChart({
stateContainer={stateContainer}
/>
</section>
<EuiSpacer size="s" />
{appendHistogram}
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -18,6 +18,8 @@ import {
isErrorEmbeddable,
} from '@kbn/embeddable-plugin/public';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/react';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { FIELD_STATISTICS_LOADED } from './constants';
import type { GetStateReturn } from '../../services/discover_state';
@ -226,13 +228,22 @@ export const FieldStatisticsTable = (props: FieldStatisticsTableProps) => {
};
}, [embeddable, embeddableRoot, trackUiMetric]);
const statsTableCss = css`
overflow-y: auto;
.kbnDocTableWrapper {
overflow-x: hidden;
}
`;
return (
<div
data-test-subj="dscFieldStatsEmbeddedContent"
ref={embeddableRoot}
style={{ height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
// Match the scroll bar of the Discover doc table
className="kbnDocTableWrapper"
/>
<EuiFlexItem css={statsTableCss}>
<div
data-test-subj="dscFieldStatsEmbeddedContent"
ref={embeddableRoot}
// Match the scroll bar of the Discover doc table
className="kbnDocTableWrapper"
/>
</EuiFlexItem>
);
};

View file

@ -70,7 +70,9 @@ discover-app {
}
.dscTimechart {
display: block;
flex-grow: 1;
display: flex;
flex-direction: column;
position: relative;
// SASSTODO: the visualizing component should have an option or a modifier
@ -81,13 +83,12 @@ discover-app {
}
.dscHistogram {
height: $euiSize * 7;
padding: 0 $euiSizeS $euiSizeS * 2 $euiSizeS;
flex-grow: 1;
padding: 0 $euiSizeS $euiSizeS $euiSizeS;
}
.dscHistogramTimeRange {
padding: 0 $euiSizeS 0 $euiSizeS;
margin-top: - $euiSizeS;
}
.dscTable {

View file

@ -175,24 +175,27 @@ function mountComponent(
describe('Discover component', () => {
test('selected data view without time field displays no chart toggle', () => {
const component = mountComponent(dataViewMock);
expect(component.find('[data-test-subj="discoverChartOptionsToggle"]').exists()).toBeFalsy();
const container = document.createElement('div');
mountComponent(dataViewMock, undefined, { attachTo: container });
expect(container.querySelector('[data-test-subj="discoverChartOptionsToggle"]')).toBeNull();
});
test('selected data view with time field displays chart toggle', () => {
const component = mountComponent(dataViewWithTimefieldMock);
expect(component.find('[data-test-subj="discoverChartOptionsToggle"]').exists()).toBeTruthy();
const container = document.createElement('div');
mountComponent(dataViewWithTimefieldMock, undefined, { attachTo: container });
expect(container.querySelector('[data-test-subj="discoverChartOptionsToggle"]')).not.toBeNull();
});
test('sql query displays no chart toggle', () => {
const component = mountComponent(
const container = document.createElement('div');
mountComponent(
dataViewWithTimefieldMock,
false,
{},
{ attachTo: container },
{ sql: 'SELECT * FROM test' },
true
);
expect(component.find('[data-test-subj="discoverChartOptionsToggle"]').exists()).toBeFalsy();
expect(container.querySelector('[data-test-subj="discoverChartOptionsToggle"]')).toBeNull();
});
test('the saved search title h1 gains focus on navigate', () => {

View file

@ -12,7 +12,6 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiHideFor,
EuiHorizontalRule,
EuiPage,
EuiPageBody,
EuiPageContent_Deprecated as EuiPageContent,
@ -34,20 +33,17 @@ import { SEARCH_FIELDS_FROM_SOURCE, SHOW_FIELD_STATISTICS } from '../../../../..
import { popularizeField } from '../../../../utils/popularize_field';
import { DiscoverTopNav } from '../top_nav/discover_topnav';
import { DocViewFilterFn } from '../../../../services/doc_views/doc_views_types';
import { DiscoverChart } from '../chart';
import { getResultState } from '../../utils/get_result_state';
import { DiscoverUninitialized } from '../uninitialized/uninitialized';
import { DataMainMsg, RecordRawType } from '../../hooks/use_saved_search';
import { useColumns } from '../../../../hooks/use_data_grid_columns';
import { DiscoverDocuments } from './discover_documents';
import { FetchStatus } from '../../../types';
import { useDataState } from '../../hooks/use_data_state';
import { FieldStatisticsTable } from '../field_stats_table';
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
import { DOCUMENTS_VIEW_CLICK, FIELD_STATISTICS_VIEW_CLICK } from '../field_stats_table/constants';
import { hasActiveFilter } from './utils';
import { getRawRecordType } from '../../utils/get_raw_record_type';
import { SavedSearchURLConflictCallout } from '../../../../components/saved_search_url_conflict_callout/saved_search_url_conflict_callout';
import { DiscoverMainContent } from './discover_main_content';
/**
* Local storage key for sidebar persistence state
@ -56,8 +52,6 @@ export const SIDEBAR_CLOSED_KEY = 'discover:sidebarClosed';
const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
const TopNavMemoized = React.memo(DiscoverTopNav);
const DiscoverChartMemoized = React.memo(DiscoverChart);
const FieldStatisticsTableMemoized = React.memo(FieldStatisticsTable);
export function DiscoverLayout({
dataView,
@ -91,7 +85,7 @@ export function DiscoverLayout({
spaces,
inspector,
} = useDiscoverServices();
const { main$, charts$, totalHits$ } = savedSearchData$;
const { main$ } = savedSearchData$;
const dataState: DataMainMsg = useDataState(main$);
const viewMode = useMemo(() => {
@ -99,21 +93,6 @@ export function DiscoverLayout({
return state.viewMode ?? VIEW_MODE.DOCUMENT_LEVEL;
}, [uiSettings, state.viewMode]);
const setDiscoverViewMode = useCallback(
(mode: VIEW_MODE) => {
stateContainer.setAppState({ viewMode: mode });
if (trackUiMetric) {
if (mode === VIEW_MODE.AGGREGATED_LEVEL) {
trackUiMetric(METRIC_TYPE.CLICK, FIELD_STATISTICS_VIEW_CLICK);
} else {
trackUiMetric(METRIC_TYPE.CLICK, DOCUMENTS_VIEW_CLICK);
}
}
},
[trackUiMetric, stateContainer]
);
const fetchCounter = useRef<number>(0);
useEffect(() => {
@ -210,6 +189,8 @@ export function DiscoverLayout({
}
}, [dataState.error, isPlainRecord]);
const resizeRef = useRef<HTMLDivElement>(null);
return (
<EuiPage className="dscPage" data-fetch-counter={fetchCounter.current}>
<h1
@ -298,6 +279,7 @@ export function DiscoverLayout({
</EuiHideFor>
<EuiFlexItem className="dscPageContent__wrapper">
<EuiPageContent
panelRef={resizeRef}
verticalPosition={contentCentered ? 'center' : undefined}
horizontalPosition={contentCentered ? 'center' : undefined}
paddingSize="none"
@ -322,61 +304,25 @@ export function DiscoverLayout({
)}
{resultState === 'loading' && <LoadingSpinner />}
{resultState === 'ready' && (
<EuiFlexGroup
className="dscPageContent__inner"
direction="column"
alignItems="stretch"
gutterSize="none"
responsive={false}
>
{!isPlainRecord && (
<>
<EuiFlexItem grow={false}>
<DiscoverChartMemoized
resetSavedSearch={resetSavedSearch}
savedSearch={savedSearch}
savedSearchDataChart$={charts$}
savedSearchDataTotalHits$={totalHits$}
stateContainer={stateContainer}
dataView={dataView}
viewMode={viewMode}
setDiscoverViewMode={setDiscoverViewMode}
hideChart={state.hideChart}
interval={state.interval}
isTimeBased={isTimeBased}
/>
</EuiFlexItem>
<EuiHorizontalRule margin="none" />
</>
)}
{viewMode === VIEW_MODE.DOCUMENT_LEVEL ? (
<DiscoverDocuments
documents$={savedSearchData$.documents$}
expandedDoc={expandedDoc}
dataView={dataView}
navigateTo={navigateTo}
onAddFilter={!isPlainRecord ? (onAddFilter as DocViewFilterFn) : undefined}
savedSearch={savedSearch}
setExpandedDoc={setExpandedDoc}
state={state}
stateContainer={stateContainer}
onFieldEdited={!isPlainRecord ? onFieldEdited : undefined}
/>
) : (
<FieldStatisticsTableMemoized
availableFields$={savedSearchData$.availableFields$}
savedSearch={savedSearch}
dataView={dataView}
query={state.query}
filters={state.filters}
columns={columns}
stateContainer={stateContainer}
onAddFilter={!isPlainRecord ? (onAddFilter as DocViewFilterFn) : undefined}
trackUiMetric={trackUiMetric}
savedSearchRefetch$={savedSearchRefetch$}
/>
)}
</EuiFlexGroup>
<DiscoverMainContent
isPlainRecord={isPlainRecord}
dataView={dataView}
navigateTo={navigateTo}
resetSavedSearch={resetSavedSearch}
expandedDoc={expandedDoc}
setExpandedDoc={setExpandedDoc}
savedSearch={savedSearch}
savedSearchData$={savedSearchData$}
savedSearchRefetch$={savedSearchRefetch$}
state={state}
stateContainer={stateContainer}
isTimeBased={isTimeBased}
viewMode={viewMode}
onAddFilter={onAddFilter as DocViewFilterFn}
onFieldEdited={onFieldEdited}
columns={columns}
resizeRef={resizeRef}
/>
)}
</EuiPageContent>
</EuiFlexItem>

View file

@ -0,0 +1,254 @@
/*
* 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 { Subject, BehaviorSubject } from 'rxjs';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { esHits } from '../../../../__mocks__/es_hits';
import { dataViewMock } from '../../../../__mocks__/data_view';
import { savedSearchMock } from '../../../../__mocks__/saved_search';
import { GetStateReturn } from '../../services/discover_state';
import {
AvailableFields$,
DataCharts$,
DataDocuments$,
DataMain$,
DataTotalHits$,
RecordRawType,
} from '../../hooks/use_saved_search';
import { discoverServiceMock } from '../../../../__mocks__/services';
import { FetchStatus } from '../../../types';
import { Chart } from '../chart/point_series';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { buildDataTableRecord } from '../../../../utils/build_data_record';
import { DiscoverMainContent, DiscoverMainContentProps } from './discover_main_content';
import { VIEW_MODE } from '@kbn/saved-search-plugin/public';
import { DiscoverPanels, DISCOVER_PANELS_MODE } from './discover_panels';
import { euiThemeVars } from '@kbn/ui-theme';
import { CoreTheme } from '@kbn/core/public';
import { act } from 'react-dom/test-utils';
import { setTimeout } from 'timers/promises';
import { DiscoverChart } from '../chart';
import { ReactWrapper } from 'enzyme';
import { DocumentViewModeToggle } from '../../../../components/view_mode_toggle';
const mountComponent = async ({
isPlainRecord = false,
hideChart = false,
isTimeBased = true,
}: {
isPlainRecord?: boolean;
hideChart?: boolean;
isTimeBased?: boolean;
} = {}) => {
const services = discoverServiceMock;
services.data.query.timefilter.timefilter.getAbsoluteTime = () => {
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
};
const main$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
recordRawType: isPlainRecord ? RecordRawType.PLAIN : RecordRawType.DOCUMENT,
foundDocuments: true,
}) as DataMain$;
const documents$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: esHits.map((esHit) => buildDataTableRecord(esHit, dataViewMock)),
}) as DataDocuments$;
const availableFields$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
fields: [] as string[],
}) as AvailableFields$;
const totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: Number(esHits.length),
}) as DataTotalHits$;
const chartData = {
xAxisOrderedValues: [
1623880800000, 1623967200000, 1624053600000, 1624140000000, 1624226400000, 1624312800000,
1624399200000, 1624485600000, 1624572000000, 1624658400000, 1624744800000, 1624831200000,
1624917600000, 1625004000000, 1625090400000,
],
xAxisFormat: { id: 'date', params: { pattern: 'YYYY-MM-DD' } },
xAxisLabel: 'order_date per day',
yAxisFormat: { id: 'number' },
ordered: {
date: true,
interval: {
asMilliseconds: jest.fn(),
},
intervalESUnit: 'd',
intervalESValue: 1,
min: '2021-03-18T08:28:56.411Z',
max: '2021-07-01T07:28:56.411Z',
},
yAxisLabel: 'Count',
values: [
{ x: 1623880800000, y: 134 },
{ x: 1623967200000, y: 152 },
{ x: 1624053600000, y: 141 },
{ x: 1624140000000, y: 138 },
{ x: 1624226400000, y: 142 },
{ x: 1624312800000, y: 157 },
{ x: 1624399200000, y: 149 },
{ x: 1624485600000, y: 146 },
{ x: 1624572000000, y: 170 },
{ x: 1624658400000, y: 137 },
{ x: 1624744800000, y: 150 },
{ x: 1624831200000, y: 144 },
{ x: 1624917600000, y: 147 },
{ x: 1625004000000, y: 137 },
{ x: 1625090400000, y: 66 },
],
} as unknown as Chart;
const charts$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
chartData,
bucketInterval: {
scaled: true,
description: 'test',
scale: 2,
},
}) as DataCharts$;
const savedSearchData$ = {
main$,
documents$,
totalHits$,
charts$,
availableFields$,
};
const props: DiscoverMainContentProps = {
isPlainRecord,
dataView: dataViewMock,
navigateTo: jest.fn(),
resetSavedSearch: jest.fn(),
setExpandedDoc: jest.fn(),
savedSearch: savedSearchMock,
savedSearchData$,
savedSearchRefetch$: new Subject(),
state: { columns: [], hideChart },
stateContainer: {
setAppState: () => {},
appStateContainer: {
getState: () => ({
interval: 'auto',
}),
},
} as unknown as GetStateReturn,
isTimeBased,
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
onAddFilter: jest.fn(),
onFieldEdited: jest.fn(),
columns: [],
resizeRef: { current: null },
};
const coreTheme$ = new BehaviorSubject<CoreTheme>({ darkMode: false });
const component = mountWithIntl(
<KibanaContextProvider services={services}>
<KibanaThemeProvider theme$={coreTheme$}>
<DiscoverMainContent {...props} />
</KibanaThemeProvider>
</KibanaContextProvider>
);
// useIsWithinBreakpoints triggers state updates which cause act
// issues and prevent our resize events from being fired correctly
// https://github.com/enzymejs/enzyme/issues/2073
await act(() => setTimeout(0));
return component;
};
const setWindowWidth = (component: ReactWrapper, width: string) => {
window.innerWidth = parseInt(width, 10);
act(() => {
window.dispatchEvent(new Event('resize'));
});
component.update();
};
describe('Discover main content component', () => {
const windowWidth = window.innerWidth;
beforeEach(() => {
window.innerWidth = windowWidth;
});
it('should set the panels mode to DISCOVER_PANELS_MODE.RESIZABLE when viewing on medium screens and above', async () => {
const component = await mountComponent();
setWindowWidth(component, euiThemeVars.euiBreakpoints.m);
expect(component.find(DiscoverPanels).prop('mode')).toBe(DISCOVER_PANELS_MODE.RESIZABLE);
});
it('should set the panels mode to DISCOVER_PANELS_MODE.FIXED when viewing on small screens and below', async () => {
const component = await mountComponent();
setWindowWidth(component, euiThemeVars.euiBreakpoints.s);
expect(component.find(DiscoverPanels).prop('mode')).toBe(DISCOVER_PANELS_MODE.FIXED);
});
it('should set the panels mode to DISCOVER_PANELS_MODE.FIXED if hideChart is true', async () => {
const component = await mountComponent({ hideChart: true });
expect(component.find(DiscoverPanels).prop('mode')).toBe(DISCOVER_PANELS_MODE.FIXED);
});
it('should set the panels mode to DISCOVER_PANELS_MODE.FIXED if isTimeBased is false', async () => {
const component = await mountComponent({ isTimeBased: false });
expect(component.find(DiscoverPanels).prop('mode')).toBe(DISCOVER_PANELS_MODE.FIXED);
});
it('should set the panels mode to DISCOVER_PANELS_MODE.SINGLE if isPlainRecord is true', async () => {
const component = await mountComponent({ isPlainRecord: true });
expect(component.find(DiscoverPanels).prop('mode')).toBe(DISCOVER_PANELS_MODE.SINGLE);
});
it('should set a fixed height for DiscoverChart when panels mode is DISCOVER_PANELS_MODE.FIXED and hideChart is false', async () => {
const component = await mountComponent();
setWindowWidth(component, euiThemeVars.euiBreakpoints.s);
const expectedHeight = component.find(DiscoverPanels).prop('initialTopPanelHeight');
expect(component.find(DiscoverChart).childAt(0).getDOMNode()).toHaveStyle({
height: `${expectedHeight}px`,
});
});
it('should not set a fixed height for DiscoverChart when panels mode is DISCOVER_PANELS_MODE.FIXED and hideChart is true', async () => {
const component = await mountComponent({ hideChart: true });
setWindowWidth(component, euiThemeVars.euiBreakpoints.s);
const expectedHeight = component.find(DiscoverPanels).prop('initialTopPanelHeight');
expect(component.find(DiscoverChart).childAt(0).getDOMNode()).not.toHaveStyle({
height: `${expectedHeight}px`,
});
});
it('should not set a fixed height for DiscoverChart when panels mode is DISCOVER_PANELS_MODE.FIXED and isTimeBased is false', async () => {
const component = await mountComponent({ isTimeBased: false });
setWindowWidth(component, euiThemeVars.euiBreakpoints.s);
const expectedHeight = component.find(DiscoverPanels).prop('initialTopPanelHeight');
expect(component.find(DiscoverChart).childAt(0).getDOMNode()).not.toHaveStyle({
height: `${expectedHeight}px`,
});
});
it('should show DocumentViewModeToggle when isPlainRecord is false', async () => {
const component = await mountComponent();
expect(component.find(DocumentViewModeToggle).exists()).toBe(true);
});
it('should not show DocumentViewModeToggle when isPlainRecord is true', async () => {
const component = await mountComponent({ isPlainRecord: true });
expect(component.find(DocumentViewModeToggle).exists()).toBe(false);
});
});

View file

@ -0,0 +1,199 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiSpacer,
useEuiTheme,
useIsWithinBreakpoints,
} from '@elastic/eui';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import React, { RefObject, useCallback, useMemo } from 'react';
import { DataView } from '@kbn/data-views-plugin/common';
import { METRIC_TYPE } from '@kbn/analytics';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { css } from '@emotion/css';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { DataTableRecord } from '../../../../types';
import { DocumentViewModeToggle, VIEW_MODE } from '../../../../components/view_mode_toggle';
import { DocViewFilterFn } from '../../../../services/doc_views/doc_views_types';
import { DataRefetch$, SavedSearchData } from '../../hooks/use_saved_search';
import { AppState, GetStateReturn } from '../../services/discover_state';
import { DiscoverChart } from '../chart';
import { FieldStatisticsTable } from '../field_stats_table';
import { DiscoverDocuments } from './discover_documents';
import { DOCUMENTS_VIEW_CLICK, FIELD_STATISTICS_VIEW_CLICK } from '../field_stats_table/constants';
import { DiscoverPanels, DISCOVER_PANELS_MODE } from './discover_panels';
const DiscoverChartMemoized = React.memo(DiscoverChart);
const FieldStatisticsTableMemoized = React.memo(FieldStatisticsTable);
export interface DiscoverMainContentProps {
isPlainRecord: boolean;
dataView: DataView;
navigateTo: (url: string) => void;
resetSavedSearch: () => void;
expandedDoc?: DataTableRecord;
setExpandedDoc: (doc?: DataTableRecord) => void;
savedSearch: SavedSearch;
savedSearchData$: SavedSearchData;
savedSearchRefetch$: DataRefetch$;
state: AppState;
stateContainer: GetStateReturn;
isTimeBased: boolean;
viewMode: VIEW_MODE;
onAddFilter: DocViewFilterFn | undefined;
onFieldEdited: () => void;
columns: string[];
resizeRef: RefObject<HTMLDivElement>;
}
export const DiscoverMainContent = ({
isPlainRecord,
dataView,
navigateTo,
resetSavedSearch,
expandedDoc,
setExpandedDoc,
savedSearch,
savedSearchData$,
savedSearchRefetch$,
state,
stateContainer,
isTimeBased,
viewMode,
onAddFilter,
onFieldEdited,
columns,
resizeRef,
}: DiscoverMainContentProps) => {
const { trackUiMetric } = useDiscoverServices();
const setDiscoverViewMode = useCallback(
(mode: VIEW_MODE) => {
stateContainer.setAppState({ viewMode: mode });
if (trackUiMetric) {
if (mode === VIEW_MODE.AGGREGATED_LEVEL) {
trackUiMetric(METRIC_TYPE.CLICK, FIELD_STATISTICS_VIEW_CLICK);
} else {
trackUiMetric(METRIC_TYPE.CLICK, DOCUMENTS_VIEW_CLICK);
}
}
},
[trackUiMetric, stateContainer]
);
const topPanelNode = useMemo(
() => createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }),
[]
);
const mainPanelNode = useMemo(
() => createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }),
[]
);
const hideChart = state.hideChart || !isTimeBased;
const showFixedPanels = useIsWithinBreakpoints(['xs', 's']) || isPlainRecord || hideChart;
const { euiTheme } = useEuiTheme();
const topPanelHeight = euiTheme.base * 12;
const minTopPanelHeight = euiTheme.base * 8;
const minMainPanelHeight = euiTheme.base * 10;
const chartClassName =
showFixedPanels && !hideChart
? css`
height: ${topPanelHeight}px;
`
: 'eui-fullHeight';
const panelsMode = isPlainRecord
? DISCOVER_PANELS_MODE.SINGLE
: showFixedPanels
? DISCOVER_PANELS_MODE.FIXED
: DISCOVER_PANELS_MODE.RESIZABLE;
return (
<>
<InPortal node={topPanelNode}>
<DiscoverChartMemoized
className={chartClassName}
resetSavedSearch={resetSavedSearch}
savedSearch={savedSearch}
savedSearchDataChart$={savedSearchData$.charts$}
savedSearchDataTotalHits$={savedSearchData$.totalHits$}
stateContainer={stateContainer}
dataView={dataView}
hideChart={state.hideChart}
interval={state.interval}
isTimeBased={isTimeBased}
appendHistogram={showFixedPanels ? <EuiSpacer size="s" /> : <EuiSpacer size="m" />}
/>
</InPortal>
<InPortal node={mainPanelNode}>
<EuiFlexGroup
className="eui-fullHeight"
direction="column"
gutterSize="none"
responsive={false}
>
{!isPlainRecord && (
<EuiFlexItem grow={false}>
{!showFixedPanels && <EuiSpacer size="s" />}
<EuiHorizontalRule margin="none" />
<DocumentViewModeToggle
viewMode={viewMode}
setDiscoverViewMode={setDiscoverViewMode}
/>
</EuiFlexItem>
)}
{viewMode === VIEW_MODE.DOCUMENT_LEVEL ? (
<DiscoverDocuments
documents$={savedSearchData$.documents$}
expandedDoc={expandedDoc}
dataView={dataView}
navigateTo={navigateTo}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
savedSearch={savedSearch}
setExpandedDoc={setExpandedDoc}
state={state}
stateContainer={stateContainer}
onFieldEdited={!isPlainRecord ? onFieldEdited : undefined}
/>
) : (
<FieldStatisticsTableMemoized
availableFields$={savedSearchData$.availableFields$}
savedSearch={savedSearch}
dataView={dataView}
query={state.query}
filters={state.filters}
columns={columns}
stateContainer={stateContainer}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
trackUiMetric={trackUiMetric}
savedSearchRefetch$={savedSearchRefetch$}
/>
)}
</EuiFlexGroup>
</InPortal>
<DiscoverPanels
className="dscPageContent__inner"
mode={panelsMode}
resizeRef={resizeRef}
initialTopPanelHeight={topPanelHeight}
minTopPanelHeight={minTopPanelHeight}
minMainPanelHeight={minMainPanelHeight}
topPanel={<OutPortal node={topPanelNode} />}
mainPanel={<OutPortal node={mainPanelNode} />}
/>
</>
);
};

View file

@ -0,0 +1,93 @@
/*
* 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 { mount } from 'enzyme';
import React, { ReactElement, RefObject } from 'react';
import { DiscoverPanels, DISCOVER_PANELS_MODE } from './discover_panels';
import { DiscoverPanelsResizable } from './discover_panels_resizable';
import { DiscoverPanelsFixed } from './discover_panels_fixed';
describe('Discover panels component', () => {
const mountComponent = ({
mode = DISCOVER_PANELS_MODE.RESIZABLE,
resizeRef = { current: null },
initialTopPanelHeight = 200,
minTopPanelHeight = 100,
minMainPanelHeight = 100,
topPanel = <></>,
mainPanel = <></>,
}: {
mode?: DISCOVER_PANELS_MODE;
resizeRef?: RefObject<HTMLDivElement>;
initialTopPanelHeight?: number;
minTopPanelHeight?: number;
minMainPanelHeight?: number;
mainPanel?: ReactElement;
topPanel?: ReactElement;
}) => {
return mount(
<DiscoverPanels
mode={mode}
resizeRef={resizeRef}
initialTopPanelHeight={initialTopPanelHeight}
minTopPanelHeight={minTopPanelHeight}
minMainPanelHeight={minMainPanelHeight}
topPanel={topPanel}
mainPanel={mainPanel}
/>
);
};
it('should show DiscoverPanelsFixed when mode is DISCOVER_PANELS_MODE.SINGLE', () => {
const topPanel = <div data-test-subj="topPanel" />;
const mainPanel = <div data-test-subj="mainPanel" />;
const component = mountComponent({ mode: DISCOVER_PANELS_MODE.SINGLE, topPanel, mainPanel });
expect(component.find(DiscoverPanelsFixed).exists()).toBe(true);
expect(component.find(DiscoverPanelsResizable).exists()).toBe(false);
expect(component.contains(topPanel)).toBe(false);
expect(component.contains(mainPanel)).toBe(true);
});
it('should show DiscoverPanelsFixed when mode is DISCOVER_PANELS_MODE.FIXED', () => {
const topPanel = <div data-test-subj="topPanel" />;
const mainPanel = <div data-test-subj="mainPanel" />;
const component = mountComponent({ mode: DISCOVER_PANELS_MODE.FIXED, topPanel, mainPanel });
expect(component.find(DiscoverPanelsFixed).exists()).toBe(true);
expect(component.find(DiscoverPanelsResizable).exists()).toBe(false);
expect(component.contains(topPanel)).toBe(true);
expect(component.contains(mainPanel)).toBe(true);
});
it('should show DiscoverPanelsResizable when mode is DISCOVER_PANELS_MODE.RESIZABLE', () => {
const topPanel = <div data-test-subj="topPanel" />;
const mainPanel = <div data-test-subj="mainPanel" />;
const component = mountComponent({ mode: DISCOVER_PANELS_MODE.RESIZABLE, topPanel, mainPanel });
expect(component.find(DiscoverPanelsFixed).exists()).toBe(false);
expect(component.find(DiscoverPanelsResizable).exists()).toBe(true);
expect(component.contains(topPanel)).toBe(true);
expect(component.contains(mainPanel)).toBe(true);
});
it('should pass true for hideTopPanel when mode is DISCOVER_PANELS_MODE.SINGLE', () => {
const topPanel = <div data-test-subj="topPanel" />;
const mainPanel = <div data-test-subj="mainPanel" />;
const component = mountComponent({ mode: DISCOVER_PANELS_MODE.SINGLE, topPanel, mainPanel });
expect(component.find(DiscoverPanelsFixed).prop('hideTopPanel')).toBe(true);
expect(component.contains(topPanel)).toBe(false);
expect(component.contains(mainPanel)).toBe(true);
});
it('should pass false for hideTopPanel when mode is DISCOVER_PANELS_MODE.FIXED', () => {
const topPanel = <div data-test-subj="topPanel" />;
const mainPanel = <div data-test-subj="mainPanel" />;
const component = mountComponent({ mode: DISCOVER_PANELS_MODE.FIXED, topPanel, mainPanel });
expect(component.find(DiscoverPanelsFixed).prop('hideTopPanel')).toBe(false);
expect(component.contains(topPanel)).toBe(true);
expect(component.contains(mainPanel)).toBe(true);
});
});

View file

@ -0,0 +1,55 @@
/*
* 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, { ReactElement, RefObject } from 'react';
import { DiscoverPanelsResizable } from './discover_panels_resizable';
import { DiscoverPanelsFixed } from './discover_panels_fixed';
export enum DISCOVER_PANELS_MODE {
SINGLE = 'single',
FIXED = 'fixed',
RESIZABLE = 'resizable',
}
export interface DiscoverPanelsProps {
className?: string;
mode: DISCOVER_PANELS_MODE;
resizeRef: RefObject<HTMLDivElement>;
initialTopPanelHeight: number;
minTopPanelHeight: number;
minMainPanelHeight: number;
topPanel: ReactElement;
mainPanel: ReactElement;
}
const fixedModes = [DISCOVER_PANELS_MODE.SINGLE, DISCOVER_PANELS_MODE.FIXED];
export const DiscoverPanels = ({
className,
mode,
resizeRef,
initialTopPanelHeight,
minTopPanelHeight,
minMainPanelHeight,
topPanel,
mainPanel,
}: DiscoverPanelsProps) => {
const panelsProps = { className, topPanel, mainPanel };
return fixedModes.includes(mode) ? (
<DiscoverPanelsFixed hideTopPanel={mode === DISCOVER_PANELS_MODE.SINGLE} {...panelsProps} />
) : (
<DiscoverPanelsResizable
resizeRef={resizeRef}
initialTopPanelHeight={initialTopPanelHeight}
minTopPanelHeight={minTopPanelHeight}
minMainPanelHeight={minMainPanelHeight}
{...panelsProps}
/>
);
};

View file

@ -0,0 +1,43 @@
/*
* 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 { mount } from 'enzyme';
import React, { ReactElement } from 'react';
import { DiscoverPanelsFixed } from './discover_panels_fixed';
describe('Discover panels fixed', () => {
const mountComponent = ({
hideTopPanel = false,
topPanel = <></>,
mainPanel = <></>,
}: {
hideTopPanel?: boolean;
topPanel: ReactElement;
mainPanel: ReactElement;
}) => {
return mount(
<DiscoverPanelsFixed hideTopPanel={hideTopPanel} topPanel={topPanel} mainPanel={mainPanel} />
);
};
it('should render both panels when hideTopPanel is false', () => {
const topPanel = <div data-test-subj="topPanel" />;
const mainPanel = <div data-test-subj="mainPanel" />;
const component = mountComponent({ topPanel, mainPanel });
expect(component.contains(topPanel)).toBe(true);
expect(component.contains(mainPanel)).toBe(true);
});
it('should render only main panel when hideTopPanel is true', () => {
const topPanel = <div data-test-subj="topPanel" />;
const mainPanel = <div data-test-subj="mainPanel" />;
const component = mountComponent({ hideTopPanel: true, topPanel, mainPanel });
expect(component.contains(topPanel)).toBe(false);
expect(component.contains(mainPanel)).toBe(true);
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { ReactElement } from 'react';
export const DiscoverPanelsFixed = ({
className,
hideTopPanel,
topPanel,
mainPanel,
}: {
className?: string;
hideTopPanel?: boolean;
topPanel: ReactElement;
mainPanel: ReactElement;
}) => {
// By default a flex item has overflow: visible, min-height: auto, and min-width: auto.
// This can cause the item to overflow the flexbox parent when its content is too large.
// Setting the overflow to something other than visible (e.g. auto) resets the min-height
// and min-width to 0 and makes the item respect the flexbox parent's size.
// https://stackoverflow.com/questions/36247140/why-dont-flex-items-shrink-past-content-size
const mainPanelCss = css`
overflow: auto;
`;
return (
<EuiFlexGroup
className={className}
direction="column"
alignItems="stretch"
gutterSize="none"
responsive={false}
>
{!hideTopPanel && <EuiFlexItem grow={false}>{topPanel}</EuiFlexItem>}
<EuiFlexItem css={mainPanelCss}>{mainPanel}</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,188 @@
/*
* 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 { mount, ReactWrapper } from 'enzyme';
import React, { ReactElement, RefObject } from 'react';
import { DiscoverPanelsResizable } from './discover_panels_resizable';
import { act } from 'react-dom/test-utils';
const containerHeight = 1000;
const topPanelId = 'topPanel';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
useResizeObserver: jest.fn(),
useGeneratedHtmlId: jest.fn(() => topPanelId),
}));
import * as eui from '@elastic/eui';
import { waitFor } from '@testing-library/dom';
describe('Discover panels resizable', () => {
const mountComponent = ({
className = '',
resizeRef = { current: null },
initialTopPanelHeight = 0,
minTopPanelHeight = 0,
minMainPanelHeight = 0,
topPanel = <></>,
mainPanel = <></>,
attachTo,
}: {
className?: string;
resizeRef?: RefObject<HTMLDivElement>;
initialTopPanelHeight?: number;
minTopPanelHeight?: number;
minMainPanelHeight?: number;
topPanel?: ReactElement;
mainPanel?: ReactElement;
attachTo?: HTMLElement;
}) => {
return mount(
<DiscoverPanelsResizable
className={className}
resizeRef={resizeRef}
initialTopPanelHeight={initialTopPanelHeight}
minTopPanelHeight={minTopPanelHeight}
minMainPanelHeight={minMainPanelHeight}
topPanel={topPanel}
mainPanel={mainPanel}
/>,
attachTo ? { attachTo } : undefined
);
};
const expectCorrectPanelSizes = (
component: ReactWrapper,
currentContainerHeight: number,
topPanelHeight: number
) => {
const topPanelSize = (topPanelHeight / currentContainerHeight) * 100;
expect(component.find('[data-test-subj="dscResizablePanelTop"]').at(0).prop('size')).toBe(
topPanelSize
);
expect(component.find('[data-test-subj="dscResizablePanelMain"]').at(0).prop('size')).toBe(
100 - topPanelSize
);
};
const forceRender = (component: ReactWrapper) => {
component.setProps({}).update();
};
beforeEach(() => {
jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: containerHeight, width: 0 });
});
it('should render both panels', () => {
const topPanel = <div data-test-subj="topPanel" />;
const mainPanel = <div data-test-subj="mainPanel" />;
const component = mountComponent({ topPanel, mainPanel });
expect(component.contains(topPanel)).toBe(true);
expect(component.contains(mainPanel)).toBe(true);
});
it('should set the initial heights of both panels', () => {
const initialTopPanelHeight = 200;
const component = mountComponent({ initialTopPanelHeight });
expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight);
});
it('should set the correct heights of both panels when the panels are resized', () => {
const initialTopPanelHeight = 200;
const component = mountComponent({ initialTopPanelHeight });
expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight);
const newTopPanelSize = 30;
const onPanelSizeChange = component
.find('[data-test-subj="dscResizableContainer"]')
.at(0)
.prop('onPanelWidthChange') as Function;
act(() => {
onPanelSizeChange({ [topPanelId]: newTopPanelSize });
});
forceRender(component);
expectCorrectPanelSizes(component, containerHeight, containerHeight * (newTopPanelSize / 100));
});
it('should maintain the height of the top panel and resize the main panel when the container height changes', () => {
const initialTopPanelHeight = 200;
const component = mountComponent({ initialTopPanelHeight });
expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight);
const newContainerHeight = 2000;
jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: newContainerHeight, width: 0 });
forceRender(component);
expectCorrectPanelSizes(component, newContainerHeight, initialTopPanelHeight);
});
it('should resize the top panel once the main panel is at its minimum height', () => {
const initialTopPanelHeight = 500;
const minTopPanelHeight = 100;
const minMainPanelHeight = 100;
const component = mountComponent({
initialTopPanelHeight,
minTopPanelHeight,
minMainPanelHeight,
});
expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight);
const newContainerHeight = 400;
jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: newContainerHeight, width: 0 });
forceRender(component);
expectCorrectPanelSizes(component, newContainerHeight, newContainerHeight - minMainPanelHeight);
jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: containerHeight, width: 0 });
forceRender(component);
expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight);
});
it('should maintain the minimum heights of both panels when the container is too small to fit them', () => {
const initialTopPanelHeight = 500;
const minTopPanelHeight = 100;
const minMainPanelHeight = 150;
const component = mountComponent({
initialTopPanelHeight,
minTopPanelHeight,
minMainPanelHeight,
});
expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight);
const newContainerHeight = 200;
jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: newContainerHeight, width: 0 });
forceRender(component);
expect(component.find('[data-test-subj="dscResizablePanelTop"]').at(0).prop('size')).toBe(
(minTopPanelHeight / newContainerHeight) * 100
);
expect(component.find('[data-test-subj="dscResizablePanelMain"]').at(0).prop('size')).toBe(
(minMainPanelHeight / newContainerHeight) * 100
);
jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: containerHeight, width: 0 });
forceRender(component);
expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight);
});
it('should blur the resize button after a resize', async () => {
const attachTo = document.createElement('div');
document.body.appendChild(attachTo);
const component = mountComponent({ attachTo });
const wrapper = component.find('[data-test-subj="dscResizableContainerWrapper"]');
const resizeButton = component.find('button[data-test-subj="dsc-resizable-button"]');
const resizeButtonInner = component.find('[data-test-subj="dscResizableButtonInner"]');
const mouseEvent = {
pageX: 0,
pageY: 0,
clientX: 0,
clientY: 0,
};
resizeButtonInner.simulate('mousedown', mouseEvent);
resizeButton.simulate('mousedown', mouseEvent);
(resizeButton.getDOMNode() as HTMLElement).focus();
wrapper.simulate('mouseup', mouseEvent);
resizeButton.simulate('click', mouseEvent);
expect(resizeButton.getDOMNode()).toHaveFocus();
await waitFor(() => {
expect(resizeButton.getDOMNode()).not.toHaveFocus();
});
});
});

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 { EuiResizableContainer, useGeneratedHtmlId, useResizeObserver } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { ReactElement, RefObject, useCallback, useEffect, useState } from 'react';
const percentToPixels = (containerHeight: number, percentage: number) =>
Math.round(containerHeight * (percentage / 100));
const pixelsToPercent = (containerHeight: number, pixels: number) =>
+((pixels / containerHeight) * 100).toFixed(4);
export const DiscoverPanelsResizable = ({
className,
resizeRef,
initialTopPanelHeight,
minTopPanelHeight,
minMainPanelHeight,
topPanel,
mainPanel,
}: {
className?: string;
resizeRef: RefObject<HTMLDivElement>;
initialTopPanelHeight: number;
minTopPanelHeight: number;
minMainPanelHeight: number;
topPanel: ReactElement;
mainPanel: ReactElement;
}) => {
const topPanelId = useGeneratedHtmlId({ prefix: 'topPanel' });
const { height: containerHeight } = useResizeObserver(resizeRef.current);
const [topPanelHeight, setTopPanelHeight] = useState(initialTopPanelHeight);
const [panelSizes, setPanelSizes] = useState({ topPanelSize: 0, mainPanelSize: 0 });
// EuiResizableContainer doesn't work properly when used with react-reverse-portal and
// will cancel the resize. To work around this we keep track of when resizes start and
// end to toggle the rendering of a transparent overlay which prevents the cancellation.
// EUI issue: https://github.com/elastic/eui/issues/6199
const [resizeWithPortalsHackIsResizing, setResizeWithPortalsHackIsResizing] = useState(false);
const enableResizeWithPortalsHack = () => setResizeWithPortalsHackIsResizing(true);
const disableResizeWithPortalsHack = () => setResizeWithPortalsHackIsResizing(false);
const resizeWithPortalsHackFillCss = css`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
`;
const resizeWithPortalsHackButtonCss = css`
z-index: 3;
`;
const resizeWithPortalsHackButtonInnerCss = css`
${resizeWithPortalsHackFillCss}
z-index: 1;
`;
const resizeWithPortalsHackOverlayCss = css`
${resizeWithPortalsHackFillCss}
z-index: 2;
`;
// Instead of setting the panel sizes directly, we convert the top panel height
// from a percentage of the container height to a pixel value. This will trigger
// the effect below to update the panel sizes.
const onPanelSizeChange = useCallback(
({ [topPanelId]: topPanelSize }: { [key: string]: number }) => {
setTopPanelHeight(percentToPixels(containerHeight, topPanelSize));
},
[containerHeight, topPanelId]
);
// This effect will update the panel sizes based on the top panel height whenever
// it or the container height changes. This allows us to keep the height of the
// top panel panel fixed when the window is resized.
useEffect(() => {
if (!containerHeight) {
return;
}
let topPanelSize: number;
let mainPanelSize: number;
// If the container height is less than the minimum main content height
// plus the current top panel height, then we need to make some adjustments.
if (containerHeight < minMainPanelHeight + topPanelHeight) {
const newTopPanelHeight = containerHeight - minMainPanelHeight;
// Try to make the top panel height fit within the container, but if it
// doesn't then just use the minimum heights.
if (newTopPanelHeight < minTopPanelHeight) {
topPanelSize = pixelsToPercent(containerHeight, minTopPanelHeight);
mainPanelSize = pixelsToPercent(containerHeight, minMainPanelHeight);
} else {
topPanelSize = pixelsToPercent(containerHeight, newTopPanelHeight);
mainPanelSize = 100 - topPanelSize;
}
} else {
topPanelSize = pixelsToPercent(containerHeight, topPanelHeight);
mainPanelSize = 100 - topPanelSize;
}
setPanelSizes({ topPanelSize, mainPanelSize });
}, [containerHeight, topPanelHeight, minTopPanelHeight, minMainPanelHeight]);
const onResizeEnd = () => {
// We don't want the resize button to retain focus after the resize is complete,
// but EuiResizableContainer will force focus it onClick. To work around this we
// use setTimeout to wait until after onClick has been called before blurring.
if (resizeWithPortalsHackIsResizing && document.activeElement instanceof HTMLElement) {
const button = document.activeElement;
setTimeout(() => {
button.blur();
});
}
disableResizeWithPortalsHack();
};
return (
<div
className="eui-fullHeight"
onMouseUp={onResizeEnd}
onMouseLeave={onResizeEnd}
onTouchEnd={onResizeEnd}
data-test-subj="dscResizableContainerWrapper"
>
<EuiResizableContainer
className={className}
direction="vertical"
onPanelWidthChange={onPanelSizeChange}
data-test-subj="dscResizableContainer"
>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
id={topPanelId}
minSize={`${minTopPanelHeight}px`}
size={panelSizes.topPanelSize}
paddingSize="none"
data-test-subj="dscResizablePanelTop"
>
{topPanel}
</EuiResizablePanel>
<EuiResizableButton
css={resizeWithPortalsHackButtonCss}
data-test-subj="dsc-resizable-button"
>
<span
onMouseDown={enableResizeWithPortalsHack}
onTouchStart={enableResizeWithPortalsHack}
css={resizeWithPortalsHackButtonInnerCss}
data-test-subj="dscResizableButtonInner"
/>
</EuiResizableButton>
<EuiResizablePanel
minSize={`${minMainPanelHeight}px`}
size={panelSizes.mainPanelSize}
paddingSize="none"
data-test-subj="dscResizablePanelMain"
>
{mainPanel}
</EuiResizablePanel>
{resizeWithPortalsHackIsResizing ? (
<div css={resizeWithPortalsHackOverlayCss} />
) : (
<></>
)}
</>
)}
</EuiResizableContainer>
</div>
);
};

View file

@ -1 +0,0 @@
@import 'view_mode_toggle';

View file

@ -1,12 +0,0 @@
.dscViewModeToggle {
padding-right: $euiSize;
}
.fieldStatsButton {
display: flex;
align-items: center;
}
.fieldStatsBetaBadge {
margin-left: $euiSizeXS;
}

View file

@ -0,0 +1,68 @@
/*
* 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 { EuiTab } from '@elastic/eui';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import React from 'react';
import { VIEW_MODE } from './constants';
import { DocumentViewModeToggle } from './view_mode_toggle';
describe('Document view mode toggle component', () => {
const mountComponent = ({
showFieldStatistics = true,
viewMode = VIEW_MODE.DOCUMENT_LEVEL,
setDiscoverViewMode = jest.fn(),
} = {}) => {
const serivces = {
uiSettings: {
get: () => showFieldStatistics,
},
};
return mountWithIntl(
<KibanaContextProvider services={serivces}>
<DocumentViewModeToggle viewMode={viewMode} setDiscoverViewMode={setDiscoverViewMode} />
</KibanaContextProvider>
);
};
it('should render if SHOW_FIELD_STATISTICS is true', () => {
const component = mountComponent();
expect(component.isEmptyRender()).toBe(false);
});
it('should not render if SHOW_FIELD_STATISTICS is false', () => {
const component = mountComponent({ showFieldStatistics: false });
expect(component.isEmptyRender()).toBe(true);
});
it('should set the view mode to VIEW_MODE.DOCUMENT_LEVEL when dscViewModeDocumentButton is clicked', () => {
const setDiscoverViewMode = jest.fn();
const component = mountComponent({ setDiscoverViewMode });
component.find('[data-test-subj="dscViewModeDocumentButton"]').at(0).simulate('click');
expect(setDiscoverViewMode).toHaveBeenCalledWith(VIEW_MODE.DOCUMENT_LEVEL);
});
it('should set the view mode to VIEW_MODE.AGGREGATED_LEVEL when dscViewModeFieldStatsButton is clicked', () => {
const setDiscoverViewMode = jest.fn();
const component = mountComponent({ setDiscoverViewMode });
component.find('[data-test-subj="dscViewModeFieldStatsButton"]').at(0).simulate('click');
expect(setDiscoverViewMode).toHaveBeenCalledWith(VIEW_MODE.AGGREGATED_LEVEL);
});
it('should select the Documents tab if viewMode is VIEW_MODE.DOCUMENT_LEVEL', () => {
const component = mountComponent();
expect(component.find(EuiTab).at(0).prop('isSelected')).toBe(true);
});
it('should select the Field statistics tab if viewMode is VIEW_MODE.AGGREGATED_LEVEL', () => {
const component = mountComponent({ viewMode: VIEW_MODE.AGGREGATED_LEVEL });
expect(component.find(EuiTab).at(1).prop('isSelected')).toBe(true);
});
});

View file

@ -6,12 +6,22 @@
* Side Public License, v 1.
*/
import { EuiButtonGroup, EuiBetaBadge } from '@elastic/eui';
import React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiTabs,
EuiTab,
useEuiPaddingSize,
EuiBetaBadge,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { VIEW_MODE } from './constants';
import './_index.scss';
import { SHOW_FIELD_STATISTICS } from '../../../common';
import { useDiscoverServices } from '../../hooks/use_discover_services';
export const DocumentViewModeToggle = ({
viewMode,
@ -20,23 +30,47 @@ export const DocumentViewModeToggle = ({
viewMode: VIEW_MODE;
setDiscoverViewMode: (viewMode: VIEW_MODE) => void;
}) => {
const toggleButtons = useMemo(
() => [
{
id: VIEW_MODE.DOCUMENT_LEVEL,
label: i18n.translate('discover.viewModes.document.label', {
defaultMessage: 'Documents',
}),
'data-test-subj': 'dscViewModeDocumentButton',
},
{
id: VIEW_MODE.AGGREGATED_LEVEL,
label: (
<div className="fieldStatsButton" data-test-subj="dscViewModeFieldStatsButton">
const { uiSettings } = useDiscoverServices();
const tabsCss = css`
padding: 0 ${useEuiPaddingSize('s')};
background-color: ${euiThemeVars.euiPageBackgroundColor};
`;
const badgeCellCss = css`
margin-left: ${useEuiPaddingSize('s')};
`;
const showViewModeToggle = uiSettings.get(SHOW_FIELD_STATISTICS) ?? false;
if (!showViewModeToggle) {
return null;
}
return (
<EuiTabs size="s" css={tabsCss} data-test-subj="dscViewModeToggle">
<EuiTab
isSelected={viewMode === VIEW_MODE.DOCUMENT_LEVEL}
onClick={() => setDiscoverViewMode(VIEW_MODE.DOCUMENT_LEVEL)}
className="dscViewModeToggle__tab"
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)}
className="dscViewModeToggle__tab"
data-test-subj="dscViewModeFieldStatsButton"
>
<EuiFlexGroup alignItems="center" gutterSize="none" responsive={false}>
<EuiFlexItem>
<FormattedMessage
id="discover.viewModes.fieldStatistics.label"
defaultMessage="Field statistics"
/>
</EuiFlexItem>
<EuiFlexItem css={badgeCellCss}>
<EuiBetaBadge
label={i18n.translate('discover.viewModes.fieldStatistics.betaTitle', {
defaultMessage: 'Beta',
@ -44,22 +78,9 @@ export const DocumentViewModeToggle = ({
size="s"
className="fieldStatsBetaBadge"
/>
</div>
),
},
],
[]
);
return (
<EuiButtonGroup
className={'dscViewModeToggle'}
legend={i18n.translate('discover.viewModes.legend', { defaultMessage: 'View modes' })}
buttonSize={'compressed'}
options={toggleButtons}
idSelected={viewMode}
onChange={(id: string) => setDiscoverViewMode(id as VIEW_MODE)}
data-test-subj={'dscViewModeToggle'}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTab>
</EuiTabs>
);
};

View file

@ -19,6 +19,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const queryBar = getService('queryBar');
const inspector = getService('inspector');
const elasticChart = getService('elasticChart');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']);
const defaultSettings = {
@ -342,5 +343,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.timePicker.pauseAutoRefresh();
});
});
describe('resizable layout panels', () => {
it('should allow resizing the layout panels', async () => {
const resizeDistance = 100;
const topPanel = await testSubjects.find('dscResizablePanelTop');
const mainPanel = await testSubjects.find('dscResizablePanelMain');
const resizeButton = await testSubjects.find('dsc-resizable-button');
const topPanelSize = (await topPanel.getPosition()).height;
const mainPanelSize = (await mainPanel.getPosition()).height;
await browser.dragAndDrop(
{ location: resizeButton },
{ location: { x: 0, y: resizeDistance } }
);
const newTopPanelSize = (await topPanel.getPosition()).height;
const newMainPanelSize = (await mainPanel.getPosition()).height;
expect(newTopPanelSize).to.be(topPanelSize + resizeDistance);
expect(newMainPanelSize).to.be(mainPanelSize - resizeDistance);
});
});
});
}

View file

@ -2327,7 +2327,6 @@
"discover.viewModes.document.label": "Documents",
"discover.viewModes.fieldStatistics.betaTitle": "Bêta",
"discover.viewModes.fieldStatistics.label": "Statistiques de champ",
"discover.viewModes.legend": "Modes d'affichage",
"embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} a été ajouté.",
"embeddableApi.attributeService.saveToLibraryError": "Une erreur s'est produite lors de l'enregistrement. Erreur : {errorMessage}.",
"embeddableApi.errors.embeddableFactoryNotFound": "Impossible de charger {type}. Veuillez effectuer une mise à niveau vers la distribution par défaut d'Elasticsearch et de Kibana avec la licence appropriée.",

View file

@ -2323,7 +2323,6 @@
"discover.viewModes.document.label": "ドキュメント",
"discover.viewModes.fieldStatistics.betaTitle": "ベータ",
"discover.viewModes.fieldStatistics.label": "フィールド統計情報",
"discover.viewModes.legend": "表示モード",
"embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} が追加されました",
"embeddableApi.attributeService.saveToLibraryError": "保存中にエラーが発生しました。エラー:{errorMessage}",
"embeddableApi.errors.embeddableFactoryNotFound": "{type} を読み込めません。Elasticsearch と Kibana のデフォルトのディストリビューションを適切なライセンスでアップグレードしてください。",

View file

@ -2327,7 +2327,6 @@
"discover.viewModes.document.label": "文档",
"discover.viewModes.fieldStatistics.betaTitle": "公测版",
"discover.viewModes.fieldStatistics.label": "字段统计信息",
"discover.viewModes.legend": "视图模式",
"embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} 已添加",
"embeddableApi.attributeService.saveToLibraryError": "保存时出错。错误:{errorMessage}",
"embeddableApi.errors.embeddableFactoryNotFound": "{type} 无法加载。请升级到具有适当许可的默认 Elasticsearch 和 Kibana 分发。",