[Discover] Support Lens fetches across tabs (#218506)

## Summary

This PR implements support for Lens chart fetches across Discover tabs,
and restoring chart state when returning to a tab.

The Lens embeddable does not currently support continuing data fetching
after it's been unmounted, or fully support restoring chart state using
previously fetched data. The Vis team is aware of this request, but in
the meantime we're using an alternative approach that keeps Lens charts
rendered in memory for each tab that's been visited at least once. This
allows fetches to run in the background and displays the resulting chart
when switching back to tabs. Doing this involved some refactoring to
both Discover and Unified Histogram:
- Create a `ChartPortalsRenderer` wrapper component in Discover to lift
chart rendering high up in the React tree and render charts into
[reverse portals](https://github.com/httptoolkit/react-reverse-portal),
ensuring charts are not remounted when switching tabs and continue to be
rendered after switching away from a tab.
- Refactor Unified Histogram from a single "container" component into
three pieces: a `UnifiedHistogramLayout` component, a
`UnifiedHistogramChart` component, and a `useUnifiedHistogram` hook to
tie things together. This approach allows us to render the chart and
hook separately (in a reverse portal) from the layout, making it
possible to separate the lifecycle of both components without keeping
the rest of Discover's components in memory after switching tabs.
- **Important note:** This change had the side effect of bloating the
Unified Histogram page load bundle since we're now exporting a static
hook that isn't dynamically imported. We could have worked around this
by getting creative with dynamic imports, but doing that seemed hacky.
Instead I decided now was a good time to split Unified Histogram out
into a package in order to support tree shaking, which also has the
added benefits of one fewer plugins to load on startup, and simplifying
the Discover async bundles.

There is one flaw with this approach: the `useDiscoverHistogram` hook
currently depends on global services for retrieving the current query
and filters (including global filters) through the `useQuerySubscriber`
hook. This means the values can become out of sync in inactive tabs when
the user modifies them in the current tab. In practice this doesn't seem
to have an effect in most cases since we don't trigger new fetches in
inactive tabs, and sync the the current tab values to the global
services when switching back to a tab. However, we should still fix this
for the MVP with an approach that doesn't leak state since the current
approach is brittle. I avoided doing that in this PR since it would
likely cause more conflicts with #217706, and that PR may introduce a
solution to the issue anyway (syncing global state to the redux store,
which we can rely on in the hook instead).

Resolves #216475.

### Checklist

- [ ] 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/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [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
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Davis McPhee 2025-05-02 13:39:25 -03:00 committed by GitHub
parent ee74bb4e65
commit d536f85005
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
117 changed files with 1197 additions and 1382 deletions

2
.github/CODEOWNERS vendored
View file

@ -547,6 +547,7 @@ src/platform/packages/shared/kbn-ui-theme @elastic/kibana-operations
src/platform/packages/shared/kbn-unified-data-table @elastic/kibana-data-discovery @elastic/security-threat-hunting-investigations
src/platform/packages/shared/kbn-unified-doc-viewer @elastic/kibana-data-discovery
src/platform/packages/shared/kbn-unified-field-list @elastic/kibana-data-discovery
src/platform/packages/shared/kbn-unified-histogram @elastic/kibana-data-discovery
src/platform/packages/shared/kbn-unified-tabs @elastic/kibana-data-discovery
src/platform/packages/shared/kbn-unsaved-changes-prompt @elastic/kibana-management
src/platform/packages/shared/kbn-use-tracked-promise @elastic/obs-ux-logs-team
@ -703,7 +704,6 @@ src/platform/plugins/shared/telemetry_management_section @elastic/kibana-core
src/platform/plugins/shared/ui_actions @elastic/appex-sharedux
src/platform/plugins/shared/ui_actions_enhanced @elastic/appex-sharedux
src/platform/plugins/shared/unified_doc_viewer @elastic/kibana-data-discovery
src/platform/plugins/shared/unified_histogram @elastic/kibana-data-discovery
src/platform/plugins/shared/unified_search @elastic/kibana-presentation
src/platform/plugins/shared/usage_collection @elastic/kibana-core
src/platform/plugins/shared/vis_types/timeseries @elastic/kibana-visualizations

View file

@ -154,7 +154,7 @@
"unifiedDocViewer": ["src/platform/plugins/shared/unified_doc_viewer", "src/platform/packages/shared/kbn-unified-doc-viewer"],
"unifiedSearch": "src/platform/plugins/shared/unified_search",
"unifiedFieldList": "src/platform/packages/shared/kbn-unified-field-list",
"unifiedHistogram": "src/platform/plugins/shared/unified_histogram",
"unifiedHistogram": "src/platform/packages/shared/kbn-unified-histogram",
"unifiedDataTable": "src/platform/packages/shared/kbn-unified-data-table",
"unifiedTabs": "src/platform/packages/shared/kbn-unified-tabs",
"dataGridInTableSearch": "src/platform/packages/shared/kbn-data-grid-in-table-search",

View file

@ -82,7 +82,6 @@ mapped_pages:
| [uiActions](uiactions-plugin.md) | UI Actions plugins provides API to manage *triggers* and *actions*. *Trigger* is an abstract description of user's intent to perform an action (like user clicking on a value inside chart). It allows us to do runtime binding between code from different plugins. For, example one such trigger is when somebody applies filters on dashboard; another one is when somebody opens a Dashboard panel context menu. *Actions* are pieces of code that execute in response to a trigger. For example, to the dashboard filtering trigger multiple actions can be attached. Once a user filters on the dashboard all possible actions are displayed to the user in a popup menu and the user has to chose one. In general this plugin provides: - Creating custom functionality (actions). - Creating custom user interaction events (triggers). - Attaching and detaching actions to triggers. - Emitting trigger events. - Executing actions attached to a given trigger. - Exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. |
| [uiActionsEnhanced](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/ui_actions_enhanced/README.md) | Registers commercially licensed generic actions like per panel time range and contains some code that supports drilldown work. |
| [unifiedDocViewer](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/unified_doc_viewer/README.md) | This plugin contains services reliant on the plugin lifecycle for the unified doc viewer component (see @kbn/unified-doc-viewer). |
| [unifiedHistogram](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/unified_histogram/README.md) | Unified Histogram is a UX Building Block including a layout with a resizable histogram and a main display. It manages its own state and data fetching, and can easily be dropped into pages with minimal setup. |
| [unifiedSearch](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/unified_search/README.md) | Contains all the components of Kibana's unified search experience. Specifically: |
| [urlForwarding](https://github.com/elastic/kibana/blob/main/src/platform/plugins/private/url_forwarding/README.md) | This plugins contains helpers to redirect legacy URLs. It can be used to forward old URLs to their new counterparts. |
| [usageCollection](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/usage_collection/README.mdx) | The Usage Collection Service defines a set of APIs for other plugins to report the usage of their features. At the same time, it provides necessary the APIs for other services (i.e.: telemetry, monitoring, ...) to consume that usage data. |

View file

@ -998,7 +998,7 @@
"@kbn/unified-doc-viewer-plugin": "link:src/platform/plugins/shared/unified_doc_viewer",
"@kbn/unified-field-list": "link:src/platform/packages/shared/kbn-unified-field-list",
"@kbn/unified-field-list-examples-plugin": "link:examples/unified_field_list_examples",
"@kbn/unified-histogram-plugin": "link:src/platform/plugins/shared/unified_histogram",
"@kbn/unified-histogram": "link:src/platform/packages/shared/kbn-unified-histogram",
"@kbn/unified-search-plugin": "link:src/platform/plugins/shared/unified_search",
"@kbn/unified-tabs": "link:src/platform/packages/shared/kbn-unified-tabs",
"@kbn/unified-tabs-examples-plugin": "link:examples/unified_tabs_examples",

View file

@ -168,7 +168,6 @@ pageLoadAssetSize:
uiActions: 35121
uiActionsEnhanced: 38494
unifiedDocViewer: 25099
unifiedHistogram: 19928
unifiedSearch: 23000
upgradeAssistant: 81241
uptime: 60000

View file

@ -17,7 +17,7 @@ import {
import type { ResizeTrigger } from '@elastic/eui/src/components/resizable_container/types';
import { css } from '@emotion/react';
import { isEqual, round } from 'lodash';
import type { ReactElement } from 'react';
import type { ReactNode } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { ResizableLayoutDirection } from '../types';
import { getContainerSize, percentToPixels, pixelsToPercent } from './utils';
@ -47,8 +47,8 @@ export const PanelsResizable = ({
fixedPanelSizePct: number;
flexPanelSizePct: number;
};
fixedPanel: ReactElement;
flexPanel: ReactElement;
fixedPanel: ReactNode;
flexPanel: ReactNode;
resizeButtonClassName?: string;
['data-test-subj']?: string;
onFixedPanelSizeChange?: (fixedPanelSize: number) => void;

View file

@ -9,7 +9,7 @@
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/react';
import type { ReactElement } from 'react';
import type { ReactNode } from 'react';
import React from 'react';
import { ResizableLayoutDirection } from '../types';
@ -23,8 +23,8 @@ export const PanelsStatic = ({
className?: string;
direction: ResizableLayoutDirection;
hideFixedPanel?: boolean;
fixedPanel: ReactElement;
flexPanel: ReactElement;
fixedPanel: ReactNode;
flexPanel: ReactNode;
}) => {
// 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.

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ReactElement, useState } from 'react';
import { ReactNode, useState } from 'react';
import React from 'react';
import { round } from 'lodash';
import { PanelsResizable } from './panels_resizable';
@ -47,11 +47,11 @@ export interface ResizableLayoutProps {
/**
* The fixed panel
*/
fixedPanel: ReactElement;
fixedPanel: ReactNode;
/**
* The flex panel
*/
flexPanel: ReactElement;
flexPanel: ReactNode;
/**
* Class name for the resize button
*/

View file

@ -72,7 +72,6 @@ test.describe(
'kbn-ui-shared-deps-npm',
'lens',
'maps',
'unifiedHistogram',
'unifiedSearch',
]);
// Validate individual plugin bundle sizes

View file

@ -0,0 +1,3 @@
# @kbn/unified-histogram
Components for the Discover histogram chart

View file

@ -12,8 +12,8 @@ import React from 'react';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { UnifiedHistogramBreakdownContext } from '../types';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { UnifiedHistogramBreakdownContext } from '../../types';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { BreakdownFieldSelector } from './breakdown_field_selector';
describe('BreakdownFieldSelector', () => {

View file

@ -21,7 +21,7 @@ import { type DataView, DataViewField } from '@kbn/data-views-plugin/common';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils';
import { i18n } from '@kbn/i18n';
import { UnifiedHistogramBreakdownContext } from '../types';
import { UnifiedHistogramBreakdownContext } from '../../types';
import {
ToolbarSelector,
ToolbarSelectorProps,

View file

@ -13,18 +13,18 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import type { Capabilities } from '@kbn/core/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { Suggestion } from '@kbn/lens-plugin/public';
import type { UnifiedHistogramFetchStatus } from '../types';
import { Chart, type ChartProps } from './chart';
import type { UnifiedHistogramFetchStatus } from '../../types';
import { UnifiedHistogramChart, type UnifiedHistogramChartProps } from './chart';
import type { ReactWrapper } from 'enzyme';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { getLensVisMock } from '../__mocks__/lens_vis';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { getLensVisMock } from '../../__mocks__/lens_vis';
import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { Subject, of } from 'rxjs';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { dataViewMock } from '../__mocks__/data_view';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { dataViewMock } from '../../__mocks__/data_view';
import { BreakdownFieldSelector } from './breakdown_field_selector';
import { checkChartAvailability } from './check_chart_availability';
import { allSuggestionsMock } from '../__mocks__/suggestions';
import { checkChartAvailability } from './utils/check_chart_availability';
import { allSuggestionsMock } from '../../__mocks__/suggestions';
let mockUseEditVisualization: jest.Mock | undefined = jest.fn();
@ -38,7 +38,6 @@ async function mountComponent({
noHits,
noBreakdown,
chartHidden = false,
appendHistogram,
dataView = dataViewWithTimefieldMock,
allSuggestions,
isPlainRecord,
@ -51,7 +50,6 @@ async function mountComponent({
noHits?: boolean;
noBreakdown?: boolean;
chartHidden?: boolean;
appendHistogram?: ReactElement;
dataView?: DataView;
allSuggestions?: Suggestion[];
isPlainRecord?: boolean;
@ -114,7 +112,7 @@ async function mountComponent({
})
).lensService;
const props: ChartProps = {
const props: UnifiedHistogramChartProps = {
lensVisService,
dataView,
requestParams,
@ -129,7 +127,6 @@ async function mountComponent({
breakdown: noBreakdown ? undefined : { field: undefined },
isChartLoading: Boolean(isChartLoading),
isPlainRecord,
appendHistogram,
onChartHiddenChange: jest.fn(),
onTimeIntervalChange: jest.fn(),
withDefaultActions: undefined,
@ -140,7 +137,7 @@ async function mountComponent({
let instance: ReactWrapper = {} as ReactWrapper;
await act(async () => {
instance = mountWithIntl(<Chart {...props} />);
instance = mountWithIntl(<UnifiedHistogramChart {...props} />);
// wait for initial async loading to complete
await new Promise((r) => setTimeout(r, 0));
props.input$?.next({ type: 'fetch' });
@ -339,12 +336,6 @@ describe('Chart', () => {
expect(mockUseEditVisualization).toHaveBeenCalled();
});
it('should render the element passed to appendHistogram', async () => {
const appendHistogram = <div data-test-subj="appendHistogram" />;
const component = await mountComponent({ appendHistogram });
expect(component.find('[data-test-subj="appendHistogram"]').exists()).toBeTruthy();
});
it('should not render chart if data view is not time based', async () => {
const component = await mountComponent({ dataView: dataViewMock });
expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBeFalsy();

View file

@ -11,7 +11,7 @@ import React, { memo, ReactElement, useCallback, useEffect, useMemo, useState }
import { Subject } from 'rxjs';
import useObservable from 'react-use/lib/useObservable';
import { IconButtonGroup, type IconButtonGroupProps } from '@kbn/shared-ux-button-toolbar';
import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiDelayRender } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiDelayRender, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type {
EmbeddableComponentProps,
@ -26,7 +26,7 @@ import type {
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { TimeRange } from '@kbn/es-query';
import { PublishingSubject } from '@kbn/presentation-publishing';
import { RequestStatus } from '@kbn/inspector-plugin/public';
import type { RequestStatus } from '@kbn/inspector-plugin/public';
import { IKibanaSearchResponse } from '@kbn/search-types';
import type { estypes } from '@elastic/elasticsearch';
import { Histogram } from './histogram';
@ -42,8 +42,8 @@ import {
UnifiedHistogramRequestContext,
UnifiedHistogramServices,
UnifiedHistogramBucketInterval,
} from '../types';
import { UnifiedHistogramSuggestionType } from '../types';
} from '../../types';
import { UnifiedHistogramSuggestionType } from '../../types';
import { BreakdownFieldSelector } from './breakdown_field_selector';
import { TimeIntervalSelector } from './time_interval_selector';
import { useTotalHits } from './hooks/use_total_hits';
@ -52,18 +52,17 @@ import { useChartActions } from './hooks/use_chart_actions';
import { ChartConfigPanel } from './chart_config_panel';
import { useFetch } from './hooks/use_fetch';
import { useEditVisualization } from './hooks/use_edit_visualization';
import { LensVisService } from '../services/lens_vis_service';
import type { UseRequestParamsResult } from '../hooks/use_request_params';
import { removeTablesFromLensAttributes } from '../utils/lens_vis_from_table';
import { LensVisService } from '../../services/lens_vis_service';
import type { UseRequestParamsResult } from '../../hooks/use_request_params';
import { removeTablesFromLensAttributes } from '../../utils/lens_vis_from_table';
import { useLensProps } from './hooks/use_lens_props';
import { useStableCallback } from '../hooks/use_stable_callback';
import { useStableCallback } from '../../hooks/use_stable_callback';
import { buildBucketInterval } from './utils/build_bucket_interval';
export interface ChartProps {
export interface UnifiedHistogramChartProps {
abortController?: AbortController;
isChartAvailable: boolean;
hiddenPanel?: boolean;
className?: string;
services: UnifiedHistogramServices;
dataView: DataView;
requestParams: UseRequestParamsResult;
@ -75,7 +74,6 @@ export interface ChartProps {
chart?: UnifiedHistogramChartContext;
breakdown?: UnifiedHistogramBreakdownContext;
renderCustomChartToggleActions?: () => ReactElement | undefined;
appendHistogram?: ReactElement;
disableTriggers?: LensEmbeddableInput['disableTriggers'];
disabledActions?: LensEmbeddableInput['disabledActions'];
input$?: UnifiedHistogramInput$;
@ -89,15 +87,15 @@ export interface ChartProps {
onChartLoad?: (event: UnifiedHistogramChartLoadEvent) => void;
onFilter?: LensEmbeddableInput['onFilter'];
onBrushEnd?: LensEmbeddableInput['onBrushEnd'];
withDefaultActions: EmbeddableComponentProps['withDefaultActions'];
withDefaultActions?: EmbeddableComponentProps['withDefaultActions'];
columns?: DatatableColumn[];
}
const RequestStatusError: typeof RequestStatus.ERROR = 2;
const HistogramMemoized = memo(Histogram);
export function Chart({
export function UnifiedHistogramChart({
isChartAvailable,
className,
services,
dataView,
requestParams,
@ -109,9 +107,6 @@ export function Chart({
lensVisService,
isPlainRecord,
renderCustomChartToggleActions,
appendHistogram,
disableTriggers,
disabledActions,
input$: originalInput$,
lensAdapters,
dataLoading$,
@ -121,12 +116,9 @@ export function Chart({
onBreakdownFieldChange,
onTotalHitsChange,
onChartLoad,
onFilter,
onBrushEnd,
withDefaultActions,
abortController,
columns,
}: ChartProps) {
...histogramProps
}: UnifiedHistogramChartProps) {
const lensVisServiceCurrentSuggestionContext = useObservable(
lensVisService.currentSuggestionContext$
);
@ -177,7 +169,7 @@ export function Chart({
dataLoadingSubject$?: PublishingSubject<boolean | undefined>
) => {
const lensRequest = adapters?.requests?.getRequests()[0];
const requestFailed = lensRequest?.status === RequestStatus.ERROR;
const requestFailed = lensRequest?.status === RequestStatusError;
const json = lensRequest?.response?.json as
| IKibanaSearchResponse<estypes.SearchResponse>
| undefined;
@ -315,7 +307,7 @@ export function Chart({
return (
<EuiFlexGroup
{...a11yCommonProps}
className={className}
className="unifiedHistogram__chart"
direction="column"
alignItems="stretch"
gutterSize="none"
@ -413,7 +405,6 @@ export function Chart({
)}
{lensPropsContext && (
<HistogramMemoized
abortController={abortController}
services={services}
dataView={dataView}
chart={chart}
@ -421,16 +412,12 @@ export function Chart({
getTimeRange={getTimeRange}
visContext={visContext}
isPlainRecord={isPlainRecord}
disableTriggers={disableTriggers}
disabledActions={disabledActions}
onFilter={onFilter}
onBrushEnd={onBrushEnd}
withDefaultActions={withDefaultActions}
{...histogramProps}
{...lensPropsContext}
/>
)}
</section>
{appendHistogram}
<EuiSpacer size="s" />
</EuiFlexItem>
)}
{canSaveVisualization && isSaveModalVisible && visContext.attributes && (

View file

@ -12,13 +12,13 @@ import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { setTimeout } from 'timers/promises';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { currentSuggestionMock } from '../__mocks__/suggestions';
import { lensAdaptersMock } from '../__mocks__/lens_adapters';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { currentSuggestionMock } from '../../__mocks__/suggestions';
import { lensAdaptersMock } from '../../__mocks__/lens_adapters';
import { ChartConfigPanel } from './chart_config_panel';
import type { UnifiedHistogramVisContext } from '../types';
import { UnifiedHistogramSuggestionType } from '../types';
import type { UnifiedHistogramVisContext } from '../../types';
import { UnifiedHistogramSuggestionType } from '../../types';
describe('ChartConfigPanel', () => {
it('should return a jsx element to edit the visualization', async () => {

View file

@ -12,9 +12,9 @@ import type { AggregateQuery, Query } from '@kbn/es-query';
import { isEqual, isObject } from 'lodash';
import type { LensEmbeddableOutput, Suggestion } from '@kbn/lens-plugin/public';
import type { Datatable } from '@kbn/expressions-plugin/common';
import { EditLensConfigPanelComponent } from '@kbn/lens-plugin/public/plugin';
import type { EditLensConfigPanelComponent } from '@kbn/lens-plugin/public/plugin';
import { DiscoverFlyouts, dismissAllFlyoutsExceptFor } from '@kbn/discover-utils';
import { deriveLensSuggestionFromLensAttributes } from '../utils/external_vis_context';
import { deriveLensSuggestionFromLensAttributes } from '../../utils/external_vis_context';
import {
UnifiedHistogramChartLoadEvent,
@ -22,7 +22,7 @@ import {
UnifiedHistogramSuggestionContext,
UnifiedHistogramSuggestionType,
UnifiedHistogramVisContext,
} from '../types';
} from '../../types';
export function ChartConfigPanel({
services,

View file

@ -11,11 +11,11 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import { Histogram, HistogramProps } from './histogram';
import React from 'react';
import { BehaviorSubject, Subject } from 'rxjs';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { getLensVisMock } from '../__mocks__/lens_vis';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { getLensVisMock } from '../../__mocks__/lens_vis';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { createDefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
import { UnifiedHistogramInput$ } from '../types';
import { UnifiedHistogramInput$ } from '../../types';
import { act } from 'react-dom/test-utils';
import { RequestStatus } from '@kbn/inspector-plugin/public';
import { getLensProps, useLensProps } from './hooks/use_lens_props';

View file

@ -18,7 +18,7 @@ import type {
UnifiedHistogramChartContext,
UnifiedHistogramServices,
UnifiedHistogramVisContext,
} from '../types';
} from '../../types';
import { useTimeRange } from './hooks/use_time_range';
import type { LensProps } from './hooks/use_lens_props';
@ -37,7 +37,7 @@ export interface HistogramProps {
disabledActions?: LensEmbeddableInput['disabledActions'];
onFilter?: LensEmbeddableInput['onFilter'];
onBrushEnd?: LensEmbeddableInput['onBrushEnd'];
withDefaultActions: EmbeddableComponentProps['withDefaultActions'];
withDefaultActions?: EmbeddableComponentProps['withDefaultActions'];
}
export function Histogram({

View file

@ -8,7 +8,7 @@
*/
import { act, renderHook } from '@testing-library/react';
import { UnifiedHistogramChartContext } from '../../types';
import { UnifiedHistogramChartContext } from '../../../types';
import { useChartActions } from './use_chart_actions';
describe('useChartActions', () => {

View file

@ -8,7 +8,7 @@
*/
import { useCallback, useEffect, useRef } from 'react';
import type { UnifiedHistogramChartContext } from '../../types';
import type { UnifiedHistogramChartContext } from '../../../types';
export const useChartActions = ({
chart,

View file

@ -10,9 +10,9 @@
import type { DataView } from '@kbn/data-views-plugin/common';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { waitFor, renderHook } from '@testing-library/react';
import { dataViewMock } from '../../__mocks__/data_view';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { dataViewMock } from '../../../__mocks__/data_view';
import { dataViewWithTimefieldMock } from '../../../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../../../__mocks__/services';
import { useEditVisualization } from './use_edit_visualization';
const getTriggerCompatibleActions = unifiedHistogramServicesMock.uiActions

View file

@ -12,7 +12,7 @@ import type { TimeRange } from '@kbn/es-query';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import type { VISUALIZE_FIELD_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { UnifiedHistogramServices } from '../..';
import type { UnifiedHistogramServices } from '../../..';
// Avoid taking a dependency on uiActionsPlugin just for this const
const visualizeFieldTrigger: typeof VISUALIZE_FIELD_TRIGGER = 'VISUALIZE_FIELD_TRIGGER';

View file

@ -9,7 +9,7 @@
import { useFetch } from './use_fetch';
import { renderHook } from '@testing-library/react';
import { UnifiedHistogramInput$ } from '../../types';
import { UnifiedHistogramInput$ } from '../../../types';
import { Subject } from 'rxjs';
describe('useFetch', () => {

View file

@ -9,7 +9,7 @@
import { useMemo } from 'react';
import { filter, share, tap } from 'rxjs';
import { UnifiedHistogramInput$ } from '../../types';
import { UnifiedHistogramInput$ } from '../../../types';
export const useFetch = ({
input$,

View file

@ -9,9 +9,9 @@
import { act, renderHook } from '@testing-library/react';
import { Subject } from 'rxjs';
import type { UnifiedHistogramInputMessage } from '../../types';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { getLensVisMock } from '../../__mocks__/lens_vis';
import type { UnifiedHistogramInputMessage } from '../../../types';
import { dataViewWithTimefieldMock } from '../../../__mocks__/data_view_with_timefield';
import { getLensVisMock } from '../../../__mocks__/lens_vis';
import { getLensProps, useLensProps } from './use_lens_props';
describe('useLensProps', () => {

View file

@ -16,8 +16,8 @@ import type {
UnifiedHistogramInputMessage,
UnifiedHistogramRequestContext,
UnifiedHistogramVisContext,
} from '../../types';
import { useStableCallback } from '../../hooks/use_stable_callback';
} from '../../../types';
import { useStableCallback } from '../../../hooks/use_stable_callback';
export type LensProps = Pick<
EmbeddableComponentProps,

View file

@ -10,7 +10,7 @@
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import { TimeRange } from '@kbn/data-plugin/common';
import { renderHook } from '@testing-library/react';
import { UnifiedHistogramBucketInterval } from '../../types';
import { UnifiedHistogramBucketInterval } from '../../../types';
import { useTimeRange } from './use_time_range';
jest.mock('@kbn/datemath', () => ({

View file

@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo } from 'react';
import dateMath from '@kbn/datemath';
import type { TimeRange } from '@kbn/data-plugin/common';
import type { UnifiedHistogramBucketInterval } from '../../types';
import type { UnifiedHistogramBucketInterval } from '../../../types';
export const useTimeRange = ({
uiSettings,

View file

@ -8,8 +8,8 @@
*/
import { Filter } from '@kbn/es-query';
import { UnifiedHistogramFetchStatus, UnifiedHistogramInput$ } from '../../types';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { UnifiedHistogramFetchStatus, UnifiedHistogramInput$ } from '../../../types';
import { dataViewWithTimefieldMock } from '../../../__mocks__/data_view_with_timefield';
import { useTotalHits } from './use_total_hits';
import { useEffect as mockUseEffect } from 'react';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';

View file

@ -19,8 +19,8 @@ import {
UnifiedHistogramInputMessage,
UnifiedHistogramRequestContext,
UnifiedHistogramServices,
} from '../../types';
import { useStableCallback } from '../../hooks/use_stable_callback';
} from '../../../types';
import { useStableCallback } from '../../../hooks/use_stable_callback';
export const useTotalHits = ({
services,

View file

@ -7,14 +7,5 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { Plugin } from '@kbn/core/public';
export class UnifiedHistogramPublicPlugin implements Plugin<{}, {}, object, {}> {
public setup() {
return {};
}
public start() {
return {};
}
}
export { UnifiedHistogramChart, type UnifiedHistogramChartProps } from './chart';
export { checkChartAvailability } from './utils/check_chart_availability';

View file

@ -11,7 +11,7 @@ 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 type { UnifiedHistogramChartContext } from '../../types';
import { ToolbarSelector, ToolbarSelectorProps, SelectableEntry } from './toolbar_selector';
export interface TimeIntervalSelectorProps {

View file

@ -8,7 +8,7 @@
*/
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { dataViewWithTimefieldMock } from '../../../__mocks__/data_view_with_timefield';
import { calculateBounds } from '@kbn/data-plugin/public';
import { buildBucketInterval } from './build_bucket_interval';

View file

@ -11,7 +11,7 @@ import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import { DataPublicPluginStart, search, tabifyAggResponse } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { TimeRange } from '@kbn/es-query';
import type { UnifiedHistogramBucketInterval } from '../../types';
import type { UnifiedHistogramBucketInterval } from '../../../types';
import { getChartAggConfigs } from './get_chart_agg_configs';
/**

View file

@ -8,7 +8,7 @@
*/
import { type DataView, DataViewType } from '@kbn/data-views-plugin/common';
import { UnifiedHistogramChartContext } from '../types';
import { UnifiedHistogramChartContext } from '../../../types';
export function checkChartAvailability({
chart,

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { dataViewWithTimefieldMock } from '../../../__mocks__/data_view_with_timefield';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { getChartAggConfigs } from './get_chart_agg_configs';

View file

@ -7,5 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { Chart } from './chart';
export { checkChartAvailability } from './check_chart_availability';
export { UnifiedHistogramLayout, type UnifiedHistogramLayoutProps } from './layout';

View file

@ -12,16 +12,18 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import type { ReactWrapper } from 'enzyme';
import React from 'react';
import { of } from 'rxjs';
import { Chart } from '../chart';
import { UnifiedHistogramChart } from '../chart';
import {
UnifiedHistogramChartContext,
UnifiedHistogramFetchStatus,
UnifiedHistogramHitsContext,
} from '../types';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { UnifiedHistogramLayout, UnifiedHistogramLayoutProps } from './layout';
} from '../../types';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { UnifiedHistogramLayout } from './layout';
import { ResizableLayout, ResizableLayoutMode } from '@kbn/resizable-layout';
import { UseUnifiedHistogramProps, useUnifiedHistogram } from '../../hooks/use_unified_histogram';
import { act } from 'react-dom/test-utils';
let mockBreakpoint = 'l';
@ -36,54 +38,65 @@ jest.mock('@elastic/eui', () => {
});
describe('Layout', () => {
const createHits = (): UnifiedHistogramHitsContext => ({
status: UnifiedHistogramFetchStatus.complete,
total: 10,
});
const createChart = (): UnifiedHistogramChartContext => ({
hidden: false,
timeInterval: 'auto',
});
const mountComponent = async ({
services = unifiedHistogramServicesMock,
hits = createHits(),
chart = createChart(),
container = null,
hits,
chart,
topPanelHeight,
...rest
}: Partial<Omit<UnifiedHistogramLayoutProps, 'hits' | 'chart'>> & {
}: Partial<UseUnifiedHistogramProps> & {
hits?: UnifiedHistogramHitsContext | null;
chart?: UnifiedHistogramChartContext | null;
topPanelHeight?: number | null;
} = {}) => {
(searchSourceInstanceMock.fetch$ as jest.Mock).mockImplementation(
jest.fn().mockReturnValue(of({ rawResponse: { hits: { total: 2 } } }))
);
const component = mountWithIntl(
<UnifiedHistogramLayout
services={services}
hits={hits ?? undefined}
chart={chart ?? undefined}
container={container}
dataView={dataViewWithTimefieldMock}
query={{
const Wrapper = () => {
const unifiedHistogram = useUnifiedHistogram({
services,
initialState: {
totalHitsStatus: hits?.status ?? UnifiedHistogramFetchStatus.complete,
totalHitsResult: hits?.total ?? 10,
chartHidden: chart?.hidden ?? false,
timeInterval: chart?.timeInterval ?? 'auto',
},
dataView: dataViewWithTimefieldMock,
query: {
language: 'kuery',
query: '',
}}
filters={[]}
timeRange={{
},
filters: [],
timeRange: {
from: '2020-05-14T11:05:13.590',
to: '2020-05-14T11:20:13.590',
}}
lensSuggestionsApi={jest.fn()}
onSuggestionContextChange={jest.fn()}
isChartLoading={false}
{...rest}
},
isChartLoading: false,
...rest,
});
if (!unifiedHistogram.isInitialized) {
return null;
}
return (
<UnifiedHistogramLayout
container={null}
unifiedHistogramChart={<UnifiedHistogramChart {...unifiedHistogram.chartProps} />}
{...unifiedHistogram.layoutProps}
hits={hits === undefined ? unifiedHistogram.layoutProps.hits : hits ?? undefined}
chart={chart === undefined ? unifiedHistogram.layoutProps.chart : chart ?? undefined}
topPanelHeight={
topPanelHeight === undefined
? unifiedHistogram.layoutProps.topPanelHeight
: topPanelHeight ?? undefined
}
/>
);
return component;
};
const component = mountWithIntl(<Wrapper />);
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
return component.update();
};
const setBreakpoint = (component: ReactWrapper, breakpoint: string) => {
@ -109,12 +122,7 @@ describe('Layout', () => {
});
it('should set the layout mode to ResizableLayoutMode.Static if chart.hidden is true', async () => {
const component = await mountComponent({
chart: {
...createChart(),
hidden: true,
},
});
const component = await mountComponent({ chart: { timeInterval: 'auto', hidden: true } });
expect(component.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Static);
});
@ -132,16 +140,20 @@ describe('Layout', () => {
const component = await mountComponent();
setBreakpoint(component, 's');
const expectedHeight = component.find(ResizableLayout).prop('fixedPanelSize');
expect(component.find(Chart).find('div.euiFlexGroup').first().getDOMNode()).toHaveStyle({
expect(
component.find(UnifiedHistogramChart).find('div.euiFlexGroup').first().getDOMNode()
).toHaveStyle({
height: `${expectedHeight}px`,
});
});
it('should not set a fixed height for Chart when layout mode is ResizableLayoutMode.Static and chart.hidden is true', async () => {
const component = await mountComponent({ chart: { ...createChart(), hidden: true } });
const component = await mountComponent({ chart: { timeInterval: 'auto', hidden: true } });
setBreakpoint(component, 's');
const expectedHeight = component.find(ResizableLayout).prop('fixedPanelSize');
expect(component.find(Chart).find('div.euiFlexGroup').first().getDOMNode()).not.toHaveStyle({
expect(
component.find(UnifiedHistogramChart).find('div.euiFlexGroup').first().getDOMNode()
).not.toHaveStyle({
height: `${expectedHeight}px`,
});
});
@ -150,7 +162,9 @@ describe('Layout', () => {
const component = await mountComponent({ chart: null });
setBreakpoint(component, 's');
const expectedHeight = component.find(ResizableLayout).prop('fixedPanelSize');
expect(component.find(Chart).find('div.euiFlexGroup').first().getDOMNode()).not.toHaveStyle({
expect(
component.find(UnifiedHistogramChart).find('div.euiFlexGroup').first().getDOMNode()
).not.toHaveStyle({
height: `${expectedHeight}px`,
});
});
@ -158,7 +172,7 @@ describe('Layout', () => {
describe('topPanelHeight', () => {
it('should pass a default fixedPanelSize to ResizableLayout when the topPanelHeight prop is undefined', async () => {
const component = await mountComponent({ topPanelHeight: undefined });
const component = await mountComponent({ topPanelHeight: null });
expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,116 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { euiFullHeight, useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui';
import React, { PropsWithChildren, ReactNode, useState } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import {
ResizableLayout,
ResizableLayoutDirection,
ResizableLayoutMode,
} from '@kbn/resizable-layout';
import { css } from '@emotion/react';
import { UnifiedHistogramChartContext, UnifiedHistogramHitsContext } from '../../types';
export type UnifiedHistogramLayoutProps = PropsWithChildren<{
/**
* The parent container element, used to calculate the layout size
*/
container: HTMLElement | null;
/**
* The rendered UnifiedHistogramChart component
*/
unifiedHistogramChart: ReactNode;
/**
* Context object for the chart -- leave undefined to hide the chart
*/
chart?: UnifiedHistogramChartContext;
/**
* Flag to indicate if the chart is available for rendering
*/
isChartAvailable?: boolean;
/**
* Context object for the hits count -- leave undefined to hide the hits count
*/
hits?: UnifiedHistogramHitsContext;
/**
* Current top panel height -- leave undefined to use the default
*/
topPanelHeight?: number;
/**
* Callback to update the topPanelHeight prop when a resize is triggered
*/
onTopPanelHeightChange?: (topPanelHeight: number | undefined) => void;
}>;
export const UnifiedHistogramLayout = ({
container,
unifiedHistogramChart,
chart,
isChartAvailable,
hits,
topPanelHeight,
onTopPanelHeightChange,
children,
}: UnifiedHistogramLayoutProps) => {
const [mainPanelNode] = useState(() =>
createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } })
);
const isMobile = useIsWithinBreakpoints(['xs', 's']);
const showFixedPanels = isMobile || !chart || chart.hidden;
const { euiTheme } = useEuiTheme();
const defaultTopPanelHeight = euiTheme.base * 12;
const minMainPanelHeight = euiTheme.base * 10;
const chartCss =
isMobile && chart && !chart.hidden
? css`
.unifiedHistogram__chart {
height: ${defaultTopPanelHeight}px;
}
`
: css`
.unifiedHistogram__chart {
${euiFullHeight()}
}
`;
const panelsMode =
chart || hits
? showFixedPanels
? ResizableLayoutMode.Static
: ResizableLayoutMode.Resizable
: ResizableLayoutMode.Single;
const currentTopPanelHeight = topPanelHeight ?? defaultTopPanelHeight;
return (
<>
<InPortal node={mainPanelNode}>
{React.isValidElement<{ isChartAvailable?: boolean }>(children)
? React.cloneElement(children, { isChartAvailable })
: children}
</InPortal>
<ResizableLayout
mode={panelsMode}
direction={ResizableLayoutDirection.Vertical}
container={container}
fixedPanelSize={currentTopPanelHeight}
minFixedPanelSize={defaultTopPanelHeight}
minFlexPanelSize={minMainPanelHeight}
fixedPanel={unifiedHistogramChart}
flexPanel={<OutPortal node={mainPanelNode} />}
data-test-subj="unifiedHistogram"
css={chartCss}
onFixedPanelSizeChange={onTopPanelHeightChange}
/>
</>
);
};

View file

@ -12,11 +12,11 @@ import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { waitFor, renderHook, act } from '@testing-library/react';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils';
import { UnifiedHistogramFetchStatus, UnifiedHistogramSuggestionContext } from '../../types';
import { dataViewMock } from '../../__mocks__/data_view';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { lensAdaptersMock } from '../../__mocks__/lens_adapters';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { UnifiedHistogramFetchStatus, UnifiedHistogramSuggestionContext } from '../types';
import { dataViewMock } from '../__mocks__/data_view';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { lensAdaptersMock } from '../__mocks__/lens_adapters';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import {
createStateService,
UnifiedHistogramState,
@ -64,6 +64,7 @@ describe('useStateProps', () => {
columns: undefined,
breakdownField: undefined,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
})
);
expect(result.current).toMatchInlineSnapshot(`
@ -123,6 +124,7 @@ describe('useStateProps', () => {
"onTimeIntervalChange": [Function],
"onTopPanelHeightChange": [Function],
"onTotalHitsChange": [Function],
"onVisContextChanged": undefined,
"request": Object {
"adapter": RequestAdapter {
"_events": Object {},
@ -135,6 +137,7 @@ describe('useStateProps', () => {
},
"searchSessionId": "123",
},
"topPanelHeight": 100,
}
`);
});
@ -153,6 +156,7 @@ describe('useStateProps', () => {
columns: undefined,
breakdownField: undefined,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
})
);
expect(result.current).toMatchInlineSnapshot(`
@ -212,6 +216,7 @@ describe('useStateProps', () => {
"onTimeIntervalChange": [Function],
"onTopPanelHeightChange": [Function],
"onTotalHitsChange": [Function],
"onVisContextChanged": undefined,
"request": Object {
"adapter": RequestAdapter {
"_events": Object {},
@ -224,6 +229,7 @@ describe('useStateProps', () => {
},
"searchSessionId": "123",
},
"topPanelHeight": 100,
}
`);
@ -251,6 +257,7 @@ describe('useStateProps', () => {
columns: undefined,
breakdownField: undefined,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
})
);
expect(result.current.chart).toStrictEqual({ hidden: false, timeInterval: 'auto' });
@ -290,6 +297,7 @@ describe('useStateProps', () => {
columns: esqlColumns,
breakdownField,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
})
);
@ -332,6 +340,7 @@ describe('useStateProps', () => {
columns: esqlColumns,
breakdownField: undefined,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
})
);
const { onBreakdownFieldChange } = result.current;
@ -357,6 +366,7 @@ describe('useStateProps', () => {
columns: undefined,
breakdownField: undefined,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
})
);
expect(result.current).toMatchInlineSnapshot(`
@ -411,6 +421,7 @@ describe('useStateProps', () => {
"onTimeIntervalChange": [Function],
"onTopPanelHeightChange": [Function],
"onTotalHitsChange": [Function],
"onVisContextChanged": undefined,
"request": Object {
"adapter": RequestAdapter {
"_events": Object {},
@ -423,6 +434,7 @@ describe('useStateProps', () => {
},
"searchSessionId": "123",
},
"topPanelHeight": 100,
}
`);
});
@ -441,6 +453,7 @@ describe('useStateProps', () => {
columns: undefined,
breakdownField: undefined,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
})
);
expect(result.current).toMatchInlineSnapshot(`
@ -495,6 +508,7 @@ describe('useStateProps', () => {
"onTimeIntervalChange": [Function],
"onTopPanelHeightChange": [Function],
"onTotalHitsChange": [Function],
"onVisContextChanged": undefined,
"request": Object {
"adapter": RequestAdapter {
"_events": Object {},
@ -507,6 +521,7 @@ describe('useStateProps', () => {
},
"searchSessionId": "123",
},
"topPanelHeight": 100,
}
`);
});
@ -525,6 +540,7 @@ describe('useStateProps', () => {
columns: undefined,
breakdownField: undefined,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
})
);
@ -602,6 +618,7 @@ describe('useStateProps', () => {
columns: undefined,
breakdownField: undefined,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
})
);
(stateService.setLensRequestAdapter as jest.Mock).mockClear();
@ -626,6 +643,7 @@ describe('useStateProps', () => {
columns: undefined,
breakdownField: undefined,
onBreakdownFieldChange: undefined,
onVisContextChanged: undefined,
};
const hook = renderHook((props: Parameters<typeof useStateProps>[0]) => useStateProps(props), {
initialProps,

View file

@ -16,10 +16,12 @@ import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils'
import { useCallback, useEffect, useMemo } from 'react';
import {
UnifiedHistogramChartLoadEvent,
UnifiedHistogramExternalVisContextStatus,
UnifiedHistogramFetchStatus,
UnifiedHistogramServices,
UnifiedHistogramSuggestionContext,
} from '../../types';
UnifiedHistogramVisContext,
} from '../types';
import type { UnifiedHistogramStateService } from '../services/state_service';
import {
chartHiddenSelector,
@ -28,9 +30,12 @@ import {
totalHitsStatusSelector,
lensAdaptersSelector,
lensDataLoadingSelector$,
topPanelHeightSelector,
} from '../utils/state_selectors';
import { useStateSelector } from '../utils/use_state_selector';
import { useStateSelector } from './use_state_selector';
import { setBreakdownField } from '../utils/local_storage_utils';
import { exportVisContext } from '../utils/external_vis_context';
import { UseUnifiedHistogramProps } from './use_unified_histogram';
export const useStateProps = ({
services,
@ -43,6 +48,7 @@ export const useStateProps = ({
columns,
breakdownField,
onBreakdownFieldChange: originalOnBreakdownFieldChange,
onVisContextChanged: originalOnVisContextChanged,
}: {
services: UnifiedHistogramServices;
localStorageKeyPrefix: string | undefined;
@ -54,13 +60,21 @@ export const useStateProps = ({
columns: DatatableColumn[] | undefined;
breakdownField: string | undefined;
onBreakdownFieldChange: ((breakdownField: string | undefined) => void) | undefined;
onVisContextChanged:
| ((
nextVisContext: UnifiedHistogramVisContext | undefined,
externalVisContextStatus: UnifiedHistogramExternalVisContextStatus
) => void)
| undefined;
}) => {
const topPanelHeight = useStateSelector(stateService?.state$, topPanelHeightSelector);
const chartHidden = useStateSelector(stateService?.state$, chartHiddenSelector);
const timeInterval = useStateSelector(stateService?.state$, timeIntervalSelector);
const totalHitsResult = useStateSelector(stateService?.state$, totalHitsResultSelector);
const totalHitsStatus = useStateSelector(stateService?.state$, totalHitsStatusSelector);
const lensAdapters = useStateSelector(stateService?.state$, lensAdaptersSelector);
const lensDataLoading$ = useStateSelector(stateService?.state$, lensDataLoadingSelector$);
/**
* Contexts
*/
@ -132,8 +146,8 @@ export const useStateProps = ({
*/
const onTopPanelHeightChange = useCallback(
(topPanelHeight: number | undefined) => {
stateService?.setTopPanelHeight(topPanelHeight);
(newTopPanelHeight: number | undefined) => {
stateService?.setTopPanelHeight(newTopPanelHeight);
},
[stateService]
);
@ -186,6 +200,18 @@ export const useStateProps = ({
[stateService]
);
const onVisContextChanged: UseUnifiedHistogramProps['onVisContextChanged'] = useMemo(() => {
if (!originalOnVisContextChanged || !isPlainRecord) {
return undefined;
}
return (visContext, externalVisContextStatus) => {
const minifiedVisContext = exportVisContext(visContext);
originalOnVisContextChanged(minifiedVisContext, externalVisContextStatus);
};
}, [isPlainRecord, originalOnVisContextChanged]);
/**
* Effects
*/
@ -205,6 +231,7 @@ export const useStateProps = ({
}, [chart, chartHidden, stateService]);
return {
topPanelHeight,
hits,
chart,
breakdown,
@ -219,5 +246,6 @@ export const useStateProps = ({
onChartLoad,
onBreakdownFieldChange,
onSuggestionContextChange,
onVisContextChanged,
};
};

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { act } from 'react-dom/test-utils';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { useUnifiedHistogram } from './use_unified_histogram';
import { renderHook, waitFor } from '@testing-library/react';
describe('useUnifiedHistogram', () => {
it('should initialize', async () => {
const hook = renderHook(() =>
useUnifiedHistogram({
services: unifiedHistogramServicesMock,
initialState: { timeInterval: '42s' },
dataView: dataViewWithTimefieldMock,
filters: [],
query: { language: 'kuery', query: '' },
requestAdapter: new RequestAdapter(),
searchSessionId: '123',
timeRange: { from: 'now-15m', to: 'now' },
})
);
expect(hook.result.current.isInitialized).toBe(false);
expect(hook.result.current.api).toBeUndefined();
expect(hook.result.current.chartProps).toBeUndefined();
expect(hook.result.current.layoutProps).toBeUndefined();
await waitFor(() => {
expect(hook.result.current.isInitialized).toBe(true);
});
expect(hook.result.current.api).toBeDefined();
expect(hook.result.current.chartProps?.chart?.timeInterval).toBe('42s');
expect(hook.result.current.layoutProps).toBeDefined();
});
it('should trigger input$ when fetch is called', async () => {
const { result } = renderHook(() =>
useUnifiedHistogram({
services: unifiedHistogramServicesMock,
initialState: { timeInterval: '42s' },
dataView: dataViewWithTimefieldMock,
filters: [],
query: { language: 'kuery', query: '' },
requestAdapter: new RequestAdapter(),
searchSessionId: '123',
timeRange: { from: 'now-15m', to: 'now' },
})
);
await waitFor(() => {
expect(result.current.isInitialized).toBe(true);
});
const input$ = result.current.chartProps?.input$;
const inputSpy = jest.fn();
input$?.subscribe(inputSpy);
act(() => {
result.current.api?.fetch();
});
expect(inputSpy).toHaveBeenCalledTimes(1);
expect(inputSpy).toHaveBeenCalledWith({ type: 'fetch' });
});
});

View file

@ -0,0 +1,324 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/public';
import type { EmbeddableComponentProps, LensEmbeddableInput } from '@kbn/lens-plugin/public';
import { useEffect, useMemo, useState } from 'react';
import { Observable, Subject, of } from 'rxjs';
import useMount from 'react-use/lib/useMount';
import { pick } from 'lodash';
import type { DataView } from '@kbn/data-views-plugin/common';
import useObservable from 'react-use/lib/useObservable';
import { UnifiedHistogramChartProps } from '../components/chart/chart';
import {
UnifiedHistogramExternalVisContextStatus,
UnifiedHistogramInputMessage,
UnifiedHistogramRequestContext,
UnifiedHistogramServices,
UnifiedHistogramSuggestionContext,
UnifiedHistogramSuggestionType,
UnifiedHistogramVisContext,
} from '../types';
import {
UnifiedHistogramStateOptions,
UnifiedHistogramStateService,
createStateService,
} from '../services/state_service';
import { useStateProps } from './use_state_props';
import { useRequestParams } from './use_request_params';
import { LensVisService } from '../services/lens_vis_service';
import { checkChartAvailability } from '../components/chart';
import { UnifiedHistogramLayoutProps } from '../components/layout/layout';
import { getBreakdownField } from '../utils/local_storage_utils';
export type UseUnifiedHistogramProps = Omit<UnifiedHistogramStateOptions, 'services'> & {
/**
* Required services
*/
services: UnifiedHistogramServices;
/**
* The current search session ID
*/
searchSessionId?: UnifiedHistogramRequestContext['searchSessionId'];
/**
* The request adapter to use for the inspector
*/
requestAdapter?: UnifiedHistogramRequestContext['adapter'];
/**
* The abort controller to use for requests
*/
abortController?: AbortController;
/**
* The current data view
*/
dataView: DataView;
/**
* The current query
*/
query?: Query | AggregateQuery;
/**
* The current filters
*/
filters?: Filter[];
/**
* The current breakdown field
*/
breakdownField?: string;
/**
* The external custom Lens vis
*/
externalVisContext?: UnifiedHistogramVisContext;
/**
* The current time range
*/
timeRange?: TimeRange;
/**
* The relative time range, used when timeRange is an absolute range (e.g. for edit visualization button)
*/
relativeTimeRange?: TimeRange;
/**
* The current columns
*/
columns?: DatatableColumn[];
/**
* Preloaded data table sometimes used for rendering the chart in ES|QL mode
*/
table?: Datatable;
/**
* Flag indicating that the chart is currently loading
*/
isChartLoading?: boolean;
/**
* Allows users to enable/disable default actions
*/
withDefaultActions?: EmbeddableComponentProps['withDefaultActions'];
/**
* Disabled action IDs for the Lens embeddable
*/
disabledActions?: LensEmbeddableInput['disabledActions'];
/**
* Callback to pass to the Lens embeddable to handle filter changes
*/
onFilter?: LensEmbeddableInput['onFilter'];
/**
* Callback to pass to the Lens embeddable to handle brush events
*/
onBrushEnd?: LensEmbeddableInput['onBrushEnd'];
/**
* Callback to update the breakdown field -- should set {@link UnifiedHistogramBreakdownContext.field} to breakdownField
*/
onBreakdownFieldChange?: (breakdownField: string | undefined) => void;
/**
* Callback to notify about the change in Lens attributes
*/
onVisContextChanged?: (
nextVisContext: UnifiedHistogramVisContext | undefined,
externalVisContextStatus: UnifiedHistogramExternalVisContextStatus
) => void;
};
export type UnifiedHistogramApi = {
/**
* Trigger a fetch of the data
*/
fetch: () => void;
} & Pick<
UnifiedHistogramStateService,
'state$' | 'setChartHidden' | 'setTopPanelHeight' | 'setTimeInterval' | 'setTotalHits'
>;
export type UnifiedHistogramPartialLayoutProps = Omit<
UnifiedHistogramLayoutProps,
'container' | 'unifiedHistogramChart'
>;
export type UseUnifiedHistogramResult =
| { isInitialized: false; api?: undefined; chartProps?: undefined; layoutProps?: undefined }
| {
isInitialized: true;
api: UnifiedHistogramApi;
chartProps: UnifiedHistogramChartProps;
layoutProps: UnifiedHistogramPartialLayoutProps;
};
const EMPTY_SUGGESTION_CONTEXT: Observable<UnifiedHistogramSuggestionContext> = of({
suggestion: undefined,
type: UnifiedHistogramSuggestionType.unsupported,
});
export const useUnifiedHistogram = (props: UseUnifiedHistogramProps): UseUnifiedHistogramResult => {
const [stateService] = useState(() => {
const { services, initialState, localStorageKeyPrefix } = props;
return createStateService({ services, initialState, localStorageKeyPrefix });
});
const [lensVisService, setLensVisService] = useState<LensVisService>();
const [input$] = useState(() => new Subject<UnifiedHistogramInputMessage>());
const [api, setApi] = useState<UnifiedHistogramApi>();
// Load async services and initialize API
useMount(async () => {
const apiHelper = await services.lens.stateHelperApi();
setLensVisService(new LensVisService({ services, lensSuggestionsApi: apiHelper.suggestions }));
setApi({
fetch: () => {
input$.next({ type: 'fetch' });
},
...pick(
stateService,
'state$',
'setChartHidden',
'setTopPanelHeight',
'setTimeInterval',
'setTotalHits'
),
});
});
const {
services,
dataView,
query,
columns,
searchSessionId,
requestAdapter,
isChartLoading,
localStorageKeyPrefix,
filters,
timeRange,
table,
externalVisContext,
} = props;
const initialBreakdownField = useMemo(
() =>
localStorageKeyPrefix
? getBreakdownField(services.storage, localStorageKeyPrefix)
: undefined,
[localStorageKeyPrefix, services.storage]
);
const stateProps = useStateProps({
services,
localStorageKeyPrefix,
stateService,
dataView,
query,
searchSessionId,
requestAdapter,
columns,
breakdownField: 'breakdownField' in props ? props.breakdownField : initialBreakdownField,
onBreakdownFieldChange: props.onBreakdownFieldChange,
onVisContextChanged: props.onVisContextChanged,
});
const columnsMap = useMemo(() => {
return columns?.reduce<Record<string, DatatableColumn>>((acc, column) => {
acc[column.id] = column;
return acc;
}, {});
}, [columns]);
const requestParams = useRequestParams({
services,
query,
filters,
timeRange,
});
const lensVisServiceCurrentSuggestionContext = useObservable(
lensVisService?.currentSuggestionContext$ ?? EMPTY_SUGGESTION_CONTEXT
);
useEffect(() => {
if (isChartLoading || !lensVisService) {
return;
}
lensVisService.update({
externalVisContext,
queryParams: {
dataView,
query: requestParams.query,
filters: requestParams.filters,
timeRange,
isPlainRecord: stateProps.isPlainRecord,
columns,
columnsMap,
},
timeInterval: stateProps.chart?.timeInterval,
breakdownField: stateProps.breakdown?.field,
table,
onSuggestionContextChange: stateProps.onSuggestionContextChange,
onVisContextChanged: stateProps.onVisContextChanged,
});
}, [
columns,
columnsMap,
dataView,
externalVisContext,
isChartLoading,
lensVisService,
requestParams.filters,
requestParams.query,
stateProps.breakdown?.field,
stateProps.chart?.timeInterval,
stateProps.isPlainRecord,
stateProps.onSuggestionContextChange,
stateProps.onVisContextChanged,
table,
timeRange,
]);
const chart =
!lensVisServiceCurrentSuggestionContext?.type ||
lensVisServiceCurrentSuggestionContext.type === UnifiedHistogramSuggestionType.unsupported
? undefined
: stateProps.chart;
const isChartAvailable = checkChartAvailability({
chart,
dataView,
isPlainRecord: stateProps.isPlainRecord,
});
const chartProps = useMemo<UnifiedHistogramChartProps | undefined>(() => {
return lensVisService
? {
...props,
...stateProps,
input$,
chart,
isChartAvailable,
requestParams,
lensVisService,
}
: undefined;
}, [chart, input$, isChartAvailable, lensVisService, props, requestParams, stateProps]);
const layoutProps = useMemo<UnifiedHistogramPartialLayoutProps>(
() => ({
chart,
isChartAvailable,
hits: stateProps.hits,
topPanelHeight: stateProps.topPanelHeight,
onTopPanelHeightChange: stateProps.onTopPanelHeightChange,
}),
[
chart,
isChartAvailable,
stateProps.hits,
stateProps.onTopPanelHeightChange,
stateProps.topPanelHeight,
]
);
if (!api || !chartProps) {
return { isInitialized: false };
}
return {
isInitialized: true,
api,
chartProps,
layoutProps,
};
};

View file

@ -7,27 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { UnifiedHistogramPublicPlugin } from './plugin';
export type { BreakdownFieldSelectorProps } from './chart/lazy';
export { UnifiedBreakdownFieldSelector } from './chart/lazy';
export type {
UnifiedHistogramApi,
UnifiedHistogramContainerProps,
UnifiedHistogramCreationOptions,
UnifiedHistogramState,
UnifiedHistogramStateOptions,
} from './container';
export {
UnifiedHistogramContainer,
getChartHidden,
getTopPanelHeight,
getBreakdownField,
setChartHidden,
setTopPanelHeight,
setBreakdownField,
} from './container';
export type {
UnifiedHistogramServices,
UnifiedHistogramChartLoadEvent,
@ -35,6 +14,29 @@ export type {
UnifiedHistogramVisContext,
} from './types';
export { UnifiedHistogramFetchStatus, UnifiedHistogramExternalVisContextStatus } from './types';
export { canImportVisContext } from './utils/external_vis_context';
export const plugin = () => new UnifiedHistogramPublicPlugin();
export {
UnifiedBreakdownFieldSelector,
type BreakdownFieldSelectorProps,
} from './components/chart/lazy';
export { UnifiedHistogramChart, type UnifiedHistogramChartProps } from './components/chart';
export { UnifiedHistogramLayout, type UnifiedHistogramLayoutProps } from './components/layout';
export {
useUnifiedHistogram,
type UseUnifiedHistogramProps,
type UnifiedHistogramApi,
type UnifiedHistogramPartialLayoutProps,
} from './hooks/use_unified_histogram';
export type { UnifiedHistogramState } from './services/state_service';
export {
getChartHidden,
getTopPanelHeight,
getBreakdownField,
setChartHidden,
setTopPanelHeight,
setBreakdownField,
} from './utils/local_storage_utils';
export { canImportVisContext } from './utils/external_vis_context';

View file

@ -10,11 +10,5 @@
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/src/platform/plugins/shared/unified_histogram'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/src/platform/plugins/shared/unified_histogram',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/src/platform/plugins/shared/unified_histogram/{common,public,server}/**/*.{ts,tsx}',
],
roots: ['<rootDir>/src/platform/packages/shared/kbn-unified-histogram'],
};

View file

@ -0,0 +1,8 @@
{
"type": "shared-browser",
"id": "@kbn/unified-histogram",
"owner": "@elastic/kibana-data-discovery",
"group": "platform",
"visibility": "shared",
"description": "Components for the Discover histogram chart"
}

View file

@ -8,7 +8,7 @@
*/
import { Observable } from 'rxjs';
import type { UnifiedHistogramApi } from './container';
import { UnifiedHistogramApi } from './hooks/use_unified_histogram';
export const createMockUnifiedHistogramApi = () => {
const api: UnifiedHistogramApi = {

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/unified-histogram",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -34,8 +34,8 @@ import {
mapVisToChartType,
computeInterval,
} from '@kbn/visualization-utils';
import { LegendSize } from '@kbn/visualizations-plugin/public';
import { XYConfiguration } from '@kbn/visualizations-plugin/common';
import type { LegendSize } from '@kbn/visualizations-plugin/public';
import type { XYConfiguration } from '@kbn/visualizations-plugin/common';
import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { fieldSupportsBreakdown } from '@kbn/field-utils';
@ -413,6 +413,7 @@ export class LensVisService {
},
};
const legendSize: `${LegendSize.EXTRA_LARGE}` = 'xlarge';
const visualizationState = {
layers: [
{
@ -435,7 +436,7 @@ export class LensVisService {
legend: {
isVisible: true,
position: 'right',
legendSize: LegendSize.EXTRA_LARGE,
legendSize,
shouldTruncate: false,
},
preferredSeriesType: 'bar_stacked',

View file

@ -8,9 +8,9 @@
*/
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { UnifiedHistogramFetchStatus } from '../..';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { lensAdaptersMock } from '../../__mocks__/lens_adapters';
import { UnifiedHistogramFetchStatus } from '..';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { lensAdaptersMock } from '../__mocks__/lens_adapters';
import {
getChartHidden,
getTopPanelHeight,

View file

@ -10,15 +10,15 @@
import type { RequestAdapter } from '@kbn/inspector-plugin/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { PublishingSubject } from '@kbn/presentation-publishing';
import { UnifiedHistogramFetchStatus } from '../..';
import type { UnifiedHistogramServices, UnifiedHistogramChartLoadEvent } from '../../types';
import { UnifiedHistogramFetchStatus } from '..';
import type { UnifiedHistogramServices, UnifiedHistogramChartLoadEvent } from '../types';
import {
getChartHidden,
getTopPanelHeight,
setChartHidden,
setTopPanelHeight,
} from '../utils/local_storage_utils';
import type { UnifiedHistogramSuggestionContext } from '../../types';
import type { UnifiedHistogramSuggestionContext } from '../types';
/**
* The current state of the container

View file

@ -2,40 +2,39 @@
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": ["jest", "node", "react", "@emotion/react/types/css-prop"]
},
"include": [ "../../../../../typings/**/*", "common/**/*", "public/**/*", "server/**/*"],
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["target/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/data-plugin",
"@kbn/data-views-plugin",
"@kbn/lens-plugin",
"@kbn/field-formats-plugin",
"@kbn/inspector-plugin",
"@kbn/expressions-plugin",
"@kbn/test-jest-helpers",
"@kbn/i18n",
"@kbn/es-query",
"@kbn/core-ui-settings-browser",
"@kbn/datemath",
"@kbn/core-ui-settings-browser-mocks",
"@kbn/shared-ux-utility",
"@kbn/ui-actions-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/visualizations-plugin",
"@kbn/resizable-layout",
"@kbn/shared-ux-button-toolbar",
"@kbn/calculate-width-from-char-count",
"@kbn/lens-embeddable-utils",
"@kbn/i18n-react",
"@kbn/lens-plugin",
"@kbn/data-plugin",
"@kbn/field-formats-plugin",
"@kbn/data-view-utils",
"@kbn/field-utils",
"@kbn/esql-utils",
"@kbn/discover-utils",
"@kbn/visualization-utils",
"@kbn/search-types",
"@kbn/i18n",
"@kbn/test-jest-helpers",
"@kbn/core",
"@kbn/shared-ux-button-toolbar",
"@kbn/es-query",
"@kbn/presentation-publishing",
"@kbn/data-view-utils",
],
"exclude": [
"target/**/*",
"@kbn/inspector-plugin",
"@kbn/search-types",
"@kbn/discover-utils",
"@kbn/ui-actions-plugin",
"@kbn/core-ui-settings-browser-mocks",
"@kbn/core-ui-settings-browser",
"@kbn/datemath",
"@kbn/shared-ux-utility",
"@kbn/i18n-react",
"@kbn/calculate-width-from-char-count",
"@kbn/resizable-layout",
"@kbn/visualization-utils",
"@kbn/visualizations-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/lens-embeddable-utils"
]
}

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { Storage } from '@kbn/kibana-utils-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
export const CHART_HIDDEN_KEY = 'chartHidden';
export const HISTOGRAM_HEIGHT_KEY = 'histogramHeight';

View file

@ -30,7 +30,6 @@
"expressions",
"unifiedDocViewer",
"unifiedSearch",
"unifiedHistogram",
"contentManagement",
"discoverShared"
],

View file

@ -17,12 +17,14 @@ import type { RuntimeStateManager } from '../application/main/state_management/r
import {
createInternalStateStore,
createRuntimeStateManager,
selectTabRuntimeState,
} from '../application/main/state_management/redux';
import type { DiscoverServices, HistoryLocationState } from '../build_services';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
import type { History } from 'history';
import type { DiscoverCustomizationContext } from '../customizations';
import { createCustomizationService } from '../customizations/customization_service';
export function getDiscoverStateMock({
isTimeBased = true,
@ -71,6 +73,15 @@ export function getDiscoverStateMock({
internalState,
runtimeStateManager,
});
const tabRuntimeState = selectTabRuntimeState(
runtimeStateManager,
internalState.getState().tabs.unsafeCurrentId
);
tabRuntimeState.customizationService$.next({
...createCustomizationService(),
cleanup: async () => {},
});
tabRuntimeState.stateContainer$.next(container);
if (savedSearch !== false) {
container.savedSearchState.set(
savedSearch ? savedSearch : isTimeBased ? savedSearchMockWithTimeField : savedSearchMock

View file

@ -63,6 +63,9 @@ export function createDiscoverServicesMock(): DiscoverServices {
dataPlugin.query.timefilter.timefilter.getTime = jest.fn(() => {
return { from: 'now-15m', to: 'now' };
});
dataPlugin.query.timefilter.timefilter.getTimeDefaults = jest.fn(() => {
return { from: 'now-15m', to: 'now' };
});
dataPlugin.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => {
return { pause: true, value: 1000 };
});

View file

@ -0,0 +1,169 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { type HtmlPortalNode, InPortal, createHtmlPortalNode } from 'react-reverse-portal';
import { UnifiedHistogramChart, useUnifiedHistogram } from '@kbn/unified-histogram';
import { DiscoverCustomizationProvider } from '../../../../customizations';
import {
useInternalStateSelector,
type RuntimeStateManager,
selectTabRuntimeState,
useRuntimeState,
CurrentTabProvider,
RuntimeStateProvider,
useCurrentTabSelector,
} from '../../state_management/redux';
import type { DiscoverMainContentProps } from '../layout/discover_main_content';
import { DiscoverMainProvider } from '../../state_management/discover_state_provider';
import type { DiscoverStateContainer } from '../../state_management/discover_state';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import { useDiscoverHistogram } from './use_discover_histogram';
export type ChartPortalNode = HtmlPortalNode;
export type ChartPortalNodes = Record<string, ChartPortalNode>;
export const ChartPortalsRenderer = ({
runtimeStateManager,
children,
}: PropsWithChildren<{
runtimeStateManager: RuntimeStateManager;
}>) => {
const allTabIds = useInternalStateSelector((state) => state.tabs.allIds);
const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId);
const chartPortalNodes = useRef<ChartPortalNodes>({});
chartPortalNodes.current = updatePortals(chartPortalNodes.current, allTabIds);
return (
<>
{Object.keys(chartPortalNodes.current).map((tabId) => {
return (
<InPortal key={tabId} node={chartPortalNodes.current[tabId]}>
<UnifiedHistogramGuard tabId={tabId} runtimeStateManager={runtimeStateManager} />
</InPortal>
);
})}
<CurrentTabProvider
currentTabId={currentTabId}
currentChartPortalNode={chartPortalNodes.current[currentTabId]}
>
{children}
</CurrentTabProvider>
</>
);
};
const updatePortals = (portals: ChartPortalNodes, tabsIds: string[]) =>
tabsIds.reduce<ChartPortalNodes>(
(acc, tabId) => ({
...acc,
[tabId]: portals[tabId] || createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }),
}),
{}
);
interface UnifiedHistogramGuardProps {
tabId: string;
runtimeStateManager: RuntimeStateManager;
panelsToggle?: DiscoverMainContentProps['panelsToggle'];
}
const UnifiedHistogramGuard = ({
tabId,
runtimeStateManager,
panelsToggle,
}: UnifiedHistogramGuardProps) => {
const isSelected = useInternalStateSelector((state) => state.tabs.unsafeCurrentId === tabId);
const currentTabRuntimeState = selectTabRuntimeState(runtimeStateManager, tabId);
const currentCustomizationService = useRuntimeState(currentTabRuntimeState.customizationService$);
const currentStateContainer = useRuntimeState(currentTabRuntimeState.stateContainer$);
const currentDataView = useRuntimeState(currentTabRuntimeState.currentDataView$);
const adHocDataViews = useRuntimeState(runtimeStateManager.adHocDataViews$);
const isInitialized = useRef(false);
if (
(!isSelected && !isInitialized.current) ||
!currentCustomizationService ||
!currentStateContainer ||
!currentDataView ||
!currentTabRuntimeState
) {
return null;
}
isInitialized.current = true;
return (
<CurrentTabProvider currentTabId={tabId}>
<DiscoverCustomizationProvider value={currentCustomizationService}>
<DiscoverMainProvider value={currentStateContainer}>
<RuntimeStateProvider currentDataView={currentDataView} adHocDataViews={adHocDataViews}>
<UnifiedHistogramChartWrapper
stateContainer={currentStateContainer}
panelsToggle={panelsToggle}
/>
</RuntimeStateProvider>
</DiscoverMainProvider>
</DiscoverCustomizationProvider>
</CurrentTabProvider>
);
};
type UnifiedHistogramChartProps = Pick<UnifiedHistogramGuardProps, 'panelsToggle'> & {
stateContainer: DiscoverStateContainer;
};
const UnifiedHistogramChartWrapper = ({
stateContainer,
panelsToggle,
}: UnifiedHistogramChartProps) => {
const { setUnifiedHistogramApi, ...unifiedHistogramProps } = useDiscoverHistogram(stateContainer);
const unifiedHistogram = useUnifiedHistogram(unifiedHistogramProps);
useEffect(() => {
if (unifiedHistogram.isInitialized) {
setUnifiedHistogramApi(unifiedHistogram.api);
}
}, [setUnifiedHistogramApi, unifiedHistogram.api, unifiedHistogram.isInitialized]);
const currentTabId = useCurrentTabSelector((tab) => tab.id);
useEffect(() => {
if (unifiedHistogram.layoutProps) {
const currentTabRuntimeState = selectTabRuntimeState(
stateContainer.runtimeStateManager,
currentTabId
);
currentTabRuntimeState.unifiedHistogramLayoutProps$.next(unifiedHistogram.layoutProps);
}
}, [currentTabId, stateContainer.runtimeStateManager, unifiedHistogram.layoutProps]);
const isEsqlMode = useIsEsqlMode();
const renderCustomChartToggleActions = useCallback(
() =>
React.isValidElement(panelsToggle)
? React.cloneElement(panelsToggle, { renderedFor: 'histogram' })
: panelsToggle,
[panelsToggle]
);
// Initialized when the first search has been requested or
// when in ES|QL mode since search sessions are not supported
if (!unifiedHistogram.isInitialized || (!unifiedHistogramProps.searchSessionId && !isEsqlMode)) {
return null;
}
return (
<UnifiedHistogramChart
{...unifiedHistogram.chartProps}
renderCustomChartToggleActions={renderCustomChartToggleActions}
/>
);
};

View file

@ -7,5 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { UnifiedHistogramLayoutProps } from './layout';
export { UnifiedHistogramLayout } from './layout';
export { type ChartPortalNode, ChartPortalsRenderer } from './chart_portals_renderer';

View file

@ -7,7 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ReactElement } from 'react';
import React from 'react';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { renderHook, act } from '@testing-library/react';
@ -15,17 +14,15 @@ import { BehaviorSubject, Subject } from 'rxjs';
import { FetchStatus } from '../../../types';
import type { DiscoverStateContainer } from '../../state_management/discover_state';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import type { UseDiscoverHistogramProps } from './use_discover_histogram';
import { useDiscoverHistogram } from './use_discover_histogram';
import { setTimeout } from 'timers/promises';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { DiscoverMainProvider } from '../../state_management/discover_state_provider';
import { RequestAdapter } from '@kbn/inspector-plugin/public';
import type { UnifiedHistogramState } from '@kbn/unified-histogram-plugin/public';
import { UnifiedHistogramFetchStatus } from '@kbn/unified-histogram-plugin/public';
import { createMockUnifiedHistogramApi } from '@kbn/unified-histogram-plugin/public/mocks';
import type { UnifiedHistogramState } from '@kbn/unified-histogram';
import { UnifiedHistogramFetchStatus } from '@kbn/unified-histogram';
import { createMockUnifiedHistogramApi } from '@kbn/unified-histogram/mocks';
import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_messages';
import type { InspectorAdapters } from '../../hooks/use_inspector';
import type { UnifiedHistogramCustomization } from '../../../../customizations/customization_types/histogram_customization';
import { useDiscoverCustomization } from '../../../../customizations';
import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
@ -111,44 +108,26 @@ describe('useDiscoverHistogram', () => {
return stateContainer;
};
const renderUseDiscoverHistogram = async ({
stateContainer = getStateContainer(),
inspectorAdapters = { requests: new RequestAdapter() },
hideChart = false,
}: {
stateContainer?: DiscoverStateContainer;
inspectorAdapters?: InspectorAdapters;
hideChart?: boolean;
} = {}) => {
const initialProps = {
stateContainer,
inspectorAdapters,
hideChart,
};
const renderUseDiscoverHistogram = async (
stateContainer: DiscoverStateContainer = getStateContainer()
) => {
const Wrapper = ({ children }: React.PropsWithChildren<unknown>) => (
<CurrentTabProvider currentTabId={stateContainer.getCurrentTab().id}>
<DiscoverMainProvider value={stateContainer}>
<RuntimeStateProvider currentDataView={dataViewMockWithTimeField} adHocDataViews={[]}>
{children as ReactElement}
{children}
</RuntimeStateProvider>
</DiscoverMainProvider>
</CurrentTabProvider>
);
const hook = renderHook(
(props: UseDiscoverHistogramProps) => {
return useDiscoverHistogram(props);
},
{
const hook = renderHook(() => useDiscoverHistogram(stateContainer), {
wrapper: Wrapper,
initialProps,
}
);
});
await act(() => setTimeout(0));
return { hook, initialProps };
return { hook };
};
beforeEach(() => {
@ -169,9 +148,9 @@ describe('useDiscoverHistogram', () => {
});
describe('initialization', () => {
it('should return the expected parameters from getCreationOptions', async () => {
it('should return the expected parameters', async () => {
const { hook } = await renderUseDiscoverHistogram();
const params = hook.result.current.getCreationOptions();
const params = hook.result.current;
expect(params?.localStorageKeyPrefix).toBe('discover');
expect(Object.keys(params?.initialState ?? {})).toEqual([
'chartHidden',
@ -200,7 +179,7 @@ describe('useDiscoverHistogram', () => {
const api = createMockUnifiedHistogramApi();
jest.spyOn(api.state$, 'subscribe');
act(() => {
hook.result.current.ref(api);
hook.result.current.setUnifiedHistogramApi(api);
});
expect(api.state$.subscribe).toHaveBeenCalledTimes(2);
});
@ -208,7 +187,8 @@ describe('useDiscoverHistogram', () => {
it('should sync Unified Histogram state with the state container', async () => {
const stateContainer = getStateContainer();
const inspectorAdapters = { requests: new RequestAdapter(), lensRequests: undefined };
const { hook } = await renderUseDiscoverHistogram({ stateContainer, inspectorAdapters });
stateContainer.dataState.inspectorAdapters = inspectorAdapters;
const { hook } = await renderUseDiscoverHistogram(stateContainer);
const lensRequestAdapter = new RequestAdapter();
const state = {
timeInterval: '1m',
@ -219,7 +199,7 @@ describe('useDiscoverHistogram', () => {
const api = createMockUnifiedHistogramApi();
api.state$ = new BehaviorSubject({ ...state, lensRequestAdapter });
act(() => {
hook.result.current.ref(api);
hook.result.current.setUnifiedHistogramApi(api);
});
expect(inspectorAdapters.lensRequests).toBe(lensRequestAdapter);
expect(stateContainer.appState.update).toHaveBeenCalledWith({
@ -230,7 +210,7 @@ describe('useDiscoverHistogram', () => {
it('should not sync Unified Histogram state with the state container if there are no changes', async () => {
const stateContainer = getStateContainer();
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const { hook } = await renderUseDiscoverHistogram(stateContainer);
const containerState = stateContainer.appState.getState();
const state = {
timeInterval: containerState.interval,
@ -241,14 +221,14 @@ describe('useDiscoverHistogram', () => {
const api = createMockUnifiedHistogramApi();
api.state$ = new BehaviorSubject(state);
act(() => {
hook.result.current.ref(api);
hook.result.current.setUnifiedHistogramApi(api);
});
expect(stateContainer.appState.update).not.toHaveBeenCalled();
});
it('should sync the state container state with Unified Histogram', async () => {
const stateContainer = getStateContainer();
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const { hook } = await renderUseDiscoverHistogram(stateContainer);
const api = createMockUnifiedHistogramApi();
let params: Partial<UnifiedHistogramState> = {};
api.setTotalHits = jest.fn((p) => {
@ -261,7 +241,7 @@ describe('useDiscoverHistogram', () => {
params = { ...params, timeInterval };
});
act(() => {
hook.result.current.ref(api);
hook.result.current.setUnifiedHistogramApi(api);
});
stateContainer.appState.update({ hideChart: true, interval: '1m' });
expect(api.setTotalHits).not.toHaveBeenCalled();
@ -272,7 +252,7 @@ describe('useDiscoverHistogram', () => {
it('should exclude totalHitsStatus and totalHitsResult from Unified Histogram state updates', async () => {
const stateContainer = getStateContainer();
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const { hook } = await renderUseDiscoverHistogram(stateContainer);
const containerState = stateContainer.appState.getState();
const state = {
timeInterval: containerState.interval,
@ -288,7 +268,7 @@ describe('useDiscoverHistogram', () => {
const subject$ = new BehaviorSubject(state);
api.state$ = subject$;
act(() => {
hook.result.current.ref(api);
hook.result.current.setUnifiedHistogramApi(api);
});
stateContainer.appState.update({ hideChart: true });
expect(Object.keys(params ?? {})).toEqual(['chartHidden']);
@ -306,7 +286,7 @@ describe('useDiscoverHistogram', () => {
it('should update total hits when the total hits state changes', async () => {
const stateContainer = getStateContainer();
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const { hook } = await renderUseDiscoverHistogram(stateContainer);
const containerState = stateContainer.appState.getState();
const state = {
timeInterval: containerState.interval,
@ -325,7 +305,7 @@ describe('useDiscoverHistogram', () => {
result: 100,
});
act(() => {
hook.result.current.ref(api);
hook.result.current.setUnifiedHistogramApi(api);
});
expect(stateContainer.dataState.data$.totalHits$.value).toEqual({
fetchStatus: FetchStatus.COMPLETE,
@ -349,7 +329,7 @@ describe('useDiscoverHistogram', () => {
mockData.query.getState = () => mockQueryState;
const stateContainer = getStateContainer();
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const { hook } = await renderUseDiscoverHistogram(stateContainer);
const containerState = stateContainer.appState.getState();
const error = new Error('test');
const state = {
@ -369,7 +349,7 @@ describe('useDiscoverHistogram', () => {
error,
});
act(() => {
hook.result.current.ref(api);
hook.result.current.setUnifiedHistogramApi(api);
});
expect(sendErrorTo).toHaveBeenCalledWith(stateContainer.dataState.data$.totalHits$);
expect(stateContainer.dataState.data$.totalHits$.value).toEqual({
@ -384,7 +364,7 @@ describe('useDiscoverHistogram', () => {
const stateContainer = getStateContainer();
stateContainer.appState.update({ query: { esql: 'from *' } });
stateContainer.dataState.fetchChart$ = fetch$;
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const { hook } = await renderUseDiscoverHistogram(stateContainer);
act(() => {
fetch$.next();
});
@ -404,7 +384,7 @@ describe('useDiscoverHistogram', () => {
},
})
);
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const { hook } = await renderUseDiscoverHistogram(stateContainer);
act(() => {
fetch$.next();
});
@ -418,10 +398,10 @@ describe('useDiscoverHistogram', () => {
const savedSearchFetch$ = new Subject<void>();
const stateContainer = getStateContainer();
stateContainer.dataState.fetchChart$ = savedSearchFetch$;
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const { hook } = await renderUseDiscoverHistogram(stateContainer);
const api = createMockUnifiedHistogramApi();
act(() => {
hook.result.current.ref(api);
hook.result.current.setUnifiedHistogramApi(api);
});
expect(api.fetch).not.toHaveBeenCalled();
act(() => {
@ -435,7 +415,7 @@ describe('useDiscoverHistogram', () => {
test('should use custom values provided by customization fwk ', async () => {
mockUseCustomizations = true;
const stateContainer = getStateContainer();
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
const { hook } = await renderUseDiscoverHistogram(stateContainer);
expect(hook.result.current.onFilter).toEqual(mockHistogramCustomization.onFilter);
expect(hook.result.current.onBrushEnd).toEqual(mockHistogramCustomization.onBrushEnd);

View file

@ -10,16 +10,15 @@
import { useQuerySubscriber } from '@kbn/unified-field-list/src/hooks/use_query_subscriber';
import type {
UnifiedHistogramApi,
UnifiedHistogramContainerProps,
UnifiedHistogramCreationOptions,
UnifiedHistogramState,
UnifiedHistogramVisContext,
} from '@kbn/unified-histogram-plugin/public';
UseUnifiedHistogramProps,
} from '@kbn/unified-histogram';
import {
canImportVisContext,
UnifiedHistogramExternalVisContextStatus,
UnifiedHistogramFetchStatus,
} from '@kbn/unified-histogram-plugin/public';
} from '@kbn/unified-histogram';
import { isEqual } from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Observable } from 'rxjs';
@ -43,7 +42,6 @@ import { ESQL_TABLE_TYPE } from '@kbn/data-plugin/common';
import { useDiscoverCustomization } from '../../../../customizations';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { FetchStatus } from '../../../types';
import type { InspectorAdapters } from '../../hooks/use_inspector';
import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_messages';
import type { DiscoverStateContainer } from '../../state_management/discover_state';
import { addLog } from '../../../../utils/add_log';
@ -65,25 +63,15 @@ import {
const EMPTY_ESQL_COLUMNS: DatatableColumn[] = [];
const EMPTY_FILTERS: Filter[] = [];
export interface UseDiscoverHistogramProps {
stateContainer: DiscoverStateContainer;
inspectorAdapters: InspectorAdapters;
hideChart: boolean | undefined;
}
export const useDiscoverHistogram = ({
stateContainer,
inspectorAdapters,
hideChart,
}: UseDiscoverHistogramProps): Omit<
UnifiedHistogramContainerProps,
'container' | 'getCreationOptions'
> & {
ref: (api: UnifiedHistogramApi | null) => void;
getCreationOptions: () => UnifiedHistogramCreationOptions;
} => {
export const useDiscoverHistogram = (
stateContainer: DiscoverStateContainer
): UseUnifiedHistogramProps & { setUnifiedHistogramApi: (api: UnifiedHistogramApi) => void } => {
const services = useDiscoverServices();
const { main$, documents$, totalHits$ } = stateContainer.dataState.data$;
const {
data$: { main$, documents$, totalHits$ },
inspectorAdapters,
getAbortController,
} = stateContainer.dataState;
const savedSearchState = useSavedSearch();
const isEsqlMode = useIsEsqlMode();
@ -91,31 +79,17 @@ export const useDiscoverHistogram = ({
* API initialization
*/
const [unifiedHistogram, ref] = useState<UnifiedHistogramApi | null>();
const [unifiedHistogramApi, setUnifiedHistogramApi] = useState<UnifiedHistogramApi>();
const [isSuggestionLoading, setIsSuggestionLoading] = useState(false);
const getCreationOptions = useCallback(() => {
const { hideChart: chartHidden, interval: timeInterval } = stateContainer.appState.getState();
return {
localStorageKeyPrefix: 'discover',
disableAutoFetching: true,
initialState: {
chartHidden,
timeInterval,
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
totalHitsResult: undefined,
},
};
}, [stateContainer.appState]);
/**
* Sync Unified Histogram state with Discover state
*/
useEffect(() => {
const subscription = createUnifiedHistogramStateObservable(unifiedHistogram?.state$)?.subscribe(
(changes) => {
const subscription = createUnifiedHistogramStateObservable(
unifiedHistogramApi?.state$
)?.subscribe((changes) => {
const { lensRequestAdapter, ...stateChanges } = changes;
const appState = stateContainer.appState.getState();
const oldState = {
@ -131,13 +105,12 @@ export const useDiscoverHistogram = ({
if (!isEqual(oldState, newState)) {
stateContainer.appState.update(newState);
}
}
);
});
return () => {
subscription?.unsubscribe();
};
}, [inspectorAdapters, stateContainer.appState, unifiedHistogram?.state$]);
}, [inspectorAdapters, stateContainer.appState, unifiedHistogramApi?.state$]);
/**
* Sync URL query params with Unified Histogram
@ -147,11 +120,11 @@ export const useDiscoverHistogram = ({
const subscription = createAppStateObservable(stateContainer.appState.state$).subscribe(
(changes) => {
if ('timeInterval' in changes && changes.timeInterval) {
unifiedHistogram?.setTimeInterval(changes.timeInterval);
unifiedHistogramApi?.setTimeInterval(changes.timeInterval);
}
if ('chartHidden' in changes && typeof changes.chartHidden === 'boolean') {
unifiedHistogram?.setChartHidden(changes.chartHidden);
unifiedHistogramApi?.setChartHidden(changes.chartHidden);
}
}
);
@ -159,7 +132,7 @@ export const useDiscoverHistogram = ({
return () => {
subscription?.unsubscribe();
};
}, [stateContainer.appState.state$, unifiedHistogram]);
}, [stateContainer.appState.state$, unifiedHistogramApi]);
/**
* Total hits
@ -168,7 +141,7 @@ export const useDiscoverHistogram = ({
const setTotalHitsError = useMemo(() => sendErrorTo(totalHits$), [totalHits$]);
useEffect(() => {
const subscription = createTotalHitsObservable(unifiedHistogram?.state$)?.subscribe(
const subscription = createTotalHitsObservable(unifiedHistogramApi?.state$)?.subscribe(
({ status, result }) => {
if (isEsqlMode) {
// ignore histogram's total hits updates for ES|QL as Discover manages them during docs fetching
@ -221,7 +194,7 @@ export const useDiscoverHistogram = ({
totalHits$,
setTotalHitsError,
stateContainer.appState,
unifiedHistogram?.state$,
unifiedHistogramApi?.state$,
]);
/**
@ -282,7 +255,7 @@ export const useDiscoverHistogram = ({
// Handle unified histogram refetching
useEffect(() => {
if (!unifiedHistogram) {
if (!unifiedHistogramApi) {
return;
}
@ -297,7 +270,7 @@ export const useDiscoverHistogram = ({
// a refetch anyway and result in multiple unnecessary fetches.
if (isEsqlMode) {
fetchChart$ = merge(
createCurrentSuggestionObservable(unifiedHistogram.state$).pipe(map(() => 'lens')),
createCurrentSuggestionObservable(unifiedHistogramApi.state$).pipe(map(() => 'lens')),
esqlFetchComplete$.pipe(map(() => 'discover'))
).pipe(debounceTime(50));
} else {
@ -307,13 +280,13 @@ export const useDiscoverHistogram = ({
const subscription = fetchChart$.subscribe((source) => {
if (source === 'discover') addLog('Unified Histogram - Discover refetch');
if (source === 'lens') addLog('Unified Histogram - Lens suggestion refetch');
unifiedHistogram.fetch();
unifiedHistogramApi.fetch();
});
return () => {
subscription.unsubscribe();
};
}, [isEsqlMode, stateContainer.dataState.fetchChart$, esqlFetchComplete$, unifiedHistogram]);
}, [isEsqlMode, stateContainer.dataState.fetchChart$, esqlFetchComplete$, unifiedHistogramApi]);
const dataView = useCurrentDataView();
@ -380,10 +353,12 @@ export const useDiscoverHistogram = ({
[dispatch, setOverriddenVisContextAfterInvalidation, stateContainer.savedSearchState]
);
const chartHidden = useAppStateSelector((state) => state.hideChart);
const timeInterval = useAppStateSelector((state) => state.interval);
const breakdownField = useAppStateSelector((state) => state.breakdownField);
const onBreakdownFieldChange = useCallback<
NonNullable<UnifiedHistogramContainerProps['onBreakdownFieldChange']>
NonNullable<UseUnifiedHistogramProps['onBreakdownFieldChange']>
>(
(nextBreakdownField) => {
if (nextBreakdownField !== breakdownField) {
@ -394,9 +369,17 @@ export const useDiscoverHistogram = ({
);
return {
ref,
getCreationOptions,
setUnifiedHistogramApi,
services,
localStorageKeyPrefix: 'discover',
requestAdapter: inspectorAdapters.requests,
abortController: getAbortController(),
initialState: {
chartHidden,
timeInterval,
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
totalHitsResult: undefined,
},
dataView: isEsqlMode ? esqlDataView : dataView,
query: isEsqlMode ? esqlQuery : query,
filters: filtersMemoized,

View file

@ -36,10 +36,17 @@ import { act } from 'react-dom/test-utils';
import { PanelsToggle } from '../../../../components/panels_toggle';
import { createDataViewDataSource } from '../../../../../common/data_sources';
import {
CurrentTabProvider,
InternalStateProvider,
RuntimeStateProvider,
internalStateActions,
} from '../../state_management/redux';
import { ChartPortalsRenderer } from '../chart';
import { UnifiedHistogramChart } from '@kbn/unified-histogram';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
useResizeObserver: jest.fn(() => ({ width: 1000, height: 1000 })),
}));
function getStateContainer({
savedSearch,
@ -155,13 +162,15 @@ const mountComponent = async ({
const component = mountWithIntl(
<KibanaRenderContextProvider {...services.core}>
<KibanaContextProvider services={services}>
<CurrentTabProvider currentTabId={stateContainer.getCurrentTab().id}>
<InternalStateProvider store={stateContainer.internalState}>
<ChartPortalsRenderer runtimeStateManager={stateContainer.runtimeStateManager}>
<DiscoverMainProvider value={stateContainer}>
<RuntimeStateProvider currentDataView={dataView} adHocDataViews={[]}>
<DiscoverHistogramLayout {...props} />
</RuntimeStateProvider>
</DiscoverMainProvider>
</CurrentTabProvider>
</ChartPortalsRenderer>
</InternalStateProvider>
</KibanaContextProvider>
</KibanaRenderContextProvider>
);
@ -177,19 +186,19 @@ const mountComponent = async ({
describe('Discover histogram layout component', () => {
describe('render', () => {
it('should render null if there is no search session', async () => {
it('should not render chart if there is no search session', async () => {
const { component } = await mountComponent({ searchSessionId: null });
expect(component.isEmptyRender()).toBe(true);
expect(component.exists(UnifiedHistogramChart)).toBe(false);
});
it('should not render null if there is a search session', async () => {
it('should render chart if there is a search session', async () => {
const { component } = await mountComponent();
expect(component.isEmptyRender()).toBe(false);
expect(component.exists(UnifiedHistogramChart)).toBe(true);
}, 10000);
it('should not render null if there is no search session, but isEsqlMode is true', async () => {
it('should render chart if there is no search session, but isEsqlMode is true', async () => {
const { component } = await mountComponent({ isEsqlMode: true });
expect(component.isEmptyRender()).toBe(false);
expect(component.exists(UnifiedHistogramChart)).toBe(true);
});
it('should render PanelsToggle', async () => {

View file

@ -7,67 +7,40 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback } from 'react';
import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public';
import { css } from '@emotion/react';
import { useDiscoverHistogram } from './use_discover_histogram';
import React from 'react';
import { UnifiedHistogramLayout } from '@kbn/unified-histogram';
import { OutPortal } from 'react-reverse-portal';
import { type DiscoverMainContentProps, DiscoverMainContent } from './discover_main_content';
import { useAppStateSelector } from '../../state_management/discover_app_state_container';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import { useCurrentChartPortalNode, useCurrentTabRuntimeState } from '../../state_management/redux';
export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps {
container: HTMLElement | null;
}
const histogramLayoutCss = css`
height: 100%;
`;
export const DiscoverHistogramLayout = ({
dataView,
stateContainer,
container,
panelsToggle,
...mainContentProps
}: DiscoverHistogramLayoutProps) => {
const { dataState } = stateContainer;
const hideChart = useAppStateSelector((state) => state.hideChart);
const isEsqlMode = useIsEsqlMode();
const unifiedHistogramProps = useDiscoverHistogram({
stateContainer,
inspectorAdapters: dataState.inspectorAdapters,
hideChart,
});
const renderCustomChartToggleActions = useCallback(
() =>
React.isValidElement(panelsToggle)
? React.cloneElement(panelsToggle, { renderedFor: 'histogram' })
: panelsToggle,
[panelsToggle]
const chartPortalNode = useCurrentChartPortalNode();
const layoutProps = useCurrentTabRuntimeState(
mainContentProps.stateContainer.runtimeStateManager,
(tab) => tab.unifiedHistogramLayoutProps$
);
// Initialized when the first search has been requested or
// when in ES|QL mode since search sessions are not supported
if (!unifiedHistogramProps.searchSessionId && !isEsqlMode) {
if (!layoutProps) {
return null;
}
return (
<UnifiedHistogramContainer
{...unifiedHistogramProps}
requestAdapter={dataState.inspectorAdapters.requests}
<UnifiedHistogramLayout
container={container}
css={histogramLayoutCss}
renderCustomChartToggleActions={renderCustomChartToggleActions}
abortController={stateContainer.dataState.getAbortController()}
unifiedHistogramChart={
chartPortalNode ? <OutPortal node={chartPortalNode} panelsToggle={panelsToggle} /> : null
}
{...layoutProps}
>
<DiscoverMainContent
{...mainContentProps}
stateContainer={stateContainer}
dataView={dataView}
panelsToggle={panelsToggle}
/>
</UnifiedHistogramContainer>
<DiscoverMainContent {...mainContentProps} panelsToggle={panelsToggle} />
</UnifiedHistogramLayout>
);
};

View file

@ -40,10 +40,11 @@ import { ErrorCallout } from '../../../../components/common/error_callout';
import { PanelsToggle } from '../../../../components/panels_toggle';
import { createDataViewDataSource } from '../../../../../common/data_sources';
import {
CurrentTabProvider,
InternalStateProvider,
RuntimeStateProvider,
internalStateActions,
} from '../../state_management/redux';
import { ChartPortalsRenderer } from '../chart';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
@ -137,7 +138,8 @@ async function mountComponent(
const component = mountWithIntl(
<KibanaContextProvider services={services}>
<CurrentTabProvider currentTabId={stateContainer.getCurrentTab().id}>
<InternalStateProvider store={stateContainer.internalState}>
<ChartPortalsRenderer runtimeStateManager={stateContainer.runtimeStateManager}>
<DiscoverMainProvider value={stateContainer}>
<RuntimeStateProvider currentDataView={dataView} adHocDataViews={[]}>
<EuiProvider highContrastMode={false}>
@ -145,7 +147,8 @@ async function mountComponent(
</EuiProvider>
</RuntimeStateProvider>
</DiscoverMainProvider>
</CurrentTabProvider>
</ChartPortalsRenderer>
</InternalStateProvider>
</KibanaContextProvider>,
mountOptions
);

View file

@ -49,7 +49,6 @@ import type { SidebarToggleState } from '../../../types';
import { FetchStatus } from '../../../types';
import { useDataState } from '../../hooks/use_data_state';
import { SavedSearchURLConflictCallout } from '../../../../components/saved_search_url_conflict_callout/saved_search_url_conflict_callout';
import { DiscoverHistogramLayout } from './discover_histogram_layout';
import { ErrorCallout } from '../../../../components/common/error_callout';
import { addLog } from '../../../../utils/add_log';
import { DiscoverResizableLayout } from './discover_resizable_layout';
@ -59,6 +58,7 @@ import { sendErrorMsg } from '../../hooks/use_saved_search_messages';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import { useCurrentDataView, useCurrentTabSelector } from '../../state_management/redux';
import { TABS_ENABLED } from '../../../../constants';
import { DiscoverHistogramLayout } from './discover_histogram_layout';
const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
const TopNavMemoized = React.memo(DiscoverTopNav);

View file

@ -12,7 +12,6 @@ import React, { useState } from 'react';
import { pick } from 'lodash';
import { DiscoverSessionView, type DiscoverSessionViewProps } from '../session_view';
import {
CurrentTabProvider,
createTabItem,
internalStateActions,
selectAllTabs,
@ -34,17 +33,10 @@ export const TabsView = (props: DiscoverSessionViewProps) => {
<UnifiedTabs
services={services}
initialItems={initialItems}
onChanged={(updateState) => {
const updateTabsAction = internalStateActions.updateTabs(updateState);
return dispatch(updateTabsAction);
}}
onChanged={(updateState) => dispatch(internalStateActions.updateTabs(updateState))}
createItem={() => createTabItem(allTabs)}
getPreviewData={getPreviewData}
renderContent={() => (
<CurrentTabProvider currentTabId={currentTabId}>
<DiscoverSessionView key={currentTabId} {...props} />
</CurrentTabProvider>
)}
renderContent={() => <DiscoverSessionView key={currentTabId} {...props} />}
/>
);
};

View file

@ -21,7 +21,6 @@ import {
createInternalStateStore,
createRuntimeStateManager,
internalStateActions,
CurrentTabProvider,
} from './state_management/redux';
import type { RootProfileState } from '../../context_awareness';
import { useRootProfile, useDefaultAdHocDataViews } from '../../context_awareness';
@ -35,6 +34,7 @@ import {
import { useAsyncFunction } from './hooks/use_async_function';
import { TabsView } from './components/tabs_view';
import { TABS_ENABLED } from '../../constants';
import { ChartPortalsRenderer } from './components/chart';
export interface MainRouteProps {
customizationContext: DiscoverCustomizationContext;
@ -142,13 +142,13 @@ export const DiscoverMainRoute = ({
return (
<InternalStateProvider store={internalState}>
<rootProfileState.AppWrapper>
<ChartPortalsRenderer runtimeStateManager={sessionViewProps.runtimeStateManager}>
{TABS_ENABLED ? (
<TabsView {...sessionViewProps} />
) : (
<CurrentTabProvider currentTabId={internalState.getState().tabs.unsafeCurrentId}>
<DiscoverSessionView {...sessionViewProps} />
</CurrentTabProvider>
)}
</ChartPortalsRenderer>
</rootProfileState.AppWrapper>
</InternalStateProvider>
);

View file

@ -15,8 +15,8 @@ import type { FilterCompareOptions } from '@kbn/es-query';
import { COMPARE_ALL_OPTIONS, isOfAggregateQueryType, updateFilterReferences } from '@kbn/es-query';
import type { SearchSourceFields } from '@kbn/data-plugin/common';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public';
import { canImportVisContext } from '@kbn/unified-histogram-plugin/public';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram';
import { canImportVisContext } from '@kbn/unified-histogram';
import type { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public';
import { isEqual, isFunction } from 'lodash';
import { i18n } from '@kbn/i18n';

View file

@ -68,12 +68,31 @@ export const initializeSession: InternalStateThunkActionCreator<
dispatch(disconnectTab({ tabId }));
dispatch(internalStateSlice.actions.resetOnSavedSearchChange({ tabId }));
const discoverSessionLoadTracker =
services.ebtManager.trackPerformanceEvent('discoverLoadSavedSearch');
const { currentDataView$, stateContainer$, customizationService$ } = selectTabRuntimeState(
runtimeStateManager,
tabId
);
/**
* New tab initialization or existing tab re-initialization
*/
const wasTabInitialized = Boolean(stateContainer$.getValue());
if (wasTabInitialized) {
// Clear existing runtime state on re-initialization
// to ensure no stale state is used during loading
currentDataView$.next(undefined);
stateContainer$.next(undefined);
customizationService$.next(undefined);
}
/**
* "No data" checks
*/
const discoverSessionLoadTracker =
services.ebtManager.trackPerformanceEvent('discoverLoadSavedSearch');
const urlState = cleanupUrlState(
defaultUrlState ?? urlStateStorage.get<AppStateUrl>(APP_STATE_URL_KEY),
services.uiSettings
@ -124,10 +143,6 @@ export const initializeSession: InternalStateThunkActionCreator<
setBreadcrumbs({ services, titleBreadcrumbText: persistedDiscoverSession.title });
}
const { currentDataView$, stateContainer$, customizationService$ } = selectTabRuntimeState(
runtimeStateManager,
tabId
);
let dataView: DataView;
if (isOfAggregateQueryType(initialQuery)) {

View file

@ -15,7 +15,8 @@ import {
createDispatchHook,
createSelectorHook,
} from 'react-redux';
import React, { type PropsWithChildren, useMemo, createContext } from 'react';
import type { PropsWithChildren } from 'react';
import React, { useMemo, createContext } from 'react';
import { useAdHocDataViews } from './runtime_state';
import type { DiscoverInternalState, TabState } from './types';
import {
@ -25,6 +26,7 @@ import {
} from './internal_state';
import { selectTab } from './selectors';
import { type TabActionInjector, createTabActionInjector } from './utils';
import type { ChartPortalNode } from '../../components/chart';
const internalStateContext = createContext<ReactReduxContextValue>(
// Recommended approach for versions of Redux prior to v9:
@ -49,6 +51,7 @@ export const useInternalStateSelector: TypedUseSelectorHook<DiscoverInternalStat
interface CurrentTabContextValue {
currentTabId: string;
currentChartPortalNode?: ChartPortalNode;
injectCurrentTab: TabActionInjector;
}
@ -56,11 +59,16 @@ const currentTabContext = createContext<CurrentTabContextValue | undefined>(unde
export const CurrentTabProvider = ({
currentTabId,
currentChartPortalNode,
children,
}: PropsWithChildren<{ currentTabId: string }>) => {
}: PropsWithChildren<{ currentTabId: string; currentChartPortalNode?: ChartPortalNode }>) => {
const contextValue = useMemo<CurrentTabContextValue>(
() => ({ currentTabId, injectCurrentTab: createTabActionInjector(currentTabId) }),
[currentTabId]
() => ({
currentTabId,
currentChartPortalNode,
injectCurrentTab: createTabActionInjector(currentTabId),
}),
[currentChartPortalNode, currentTabId]
);
return <currentTabContext.Provider value={contextValue}>{children}</currentTabContext.Provider>;
@ -88,6 +96,8 @@ export const useCurrentTabAction = <TPayload extends TabActionPayload, TReturn>(
return useMemo(() => injectCurrentTab(actionCreator), [actionCreator, injectCurrentTab]);
};
export const useCurrentChartPortalNode = () => useCurrentTabContext().currentChartPortalNode;
export const useDataViewsForPicker = () => {
const originalAdHocDataViews = useAdHocDataViews();
const savedDataViews = useInternalStateSelector((state) => state.savedDataViews);

View file

@ -52,6 +52,7 @@ export {
CurrentTabProvider,
useCurrentTabSelector,
useCurrentTabAction,
useCurrentChartPortalNode,
useDataViewsForPicker,
} from './hooks';

View file

@ -193,11 +193,16 @@ export interface InternalStateThunkDependencies {
urlStateStorage: IKbnUrlStateStorage;
}
const IS_JEST_ENVIRONMENT = typeof jest !== 'undefined';
export const createInternalStateStore = (options: InternalStateThunkDependencies) => {
const store = configureStore({
reducer: internalStateSlice.reducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: { extraArgument: options } }),
getDefaultMiddleware({
thunk: { extraArgument: options },
serializableCheck: !IS_JEST_ENVIRONMENT,
}),
});
// TEMPORARY: Create initial default tab

View file

@ -11,6 +11,7 @@ import type { DataView } from '@kbn/data-views-plugin/common';
import React, { type PropsWithChildren, createContext, useContext, useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject } from 'rxjs';
import type { UnifiedHistogramPartialLayoutProps } from '@kbn/unified-histogram';
import { useCurrentTabContext } from './hooks';
import type { DiscoverStateContainer } from '../discover_state';
import type { ConnectedCustomizationService } from '../../../../customizations';
@ -22,6 +23,7 @@ interface DiscoverRuntimeState {
interface TabRuntimeState {
stateContainer?: DiscoverStateContainer;
customizationService?: ConnectedCustomizationService;
unifiedHistogramLayoutProps?: UnifiedHistogramPartialLayoutProps;
currentDataView: DataView;
}
@ -45,6 +47,9 @@ export const createRuntimeStateManager = (): RuntimeStateManager => ({
export const createTabRuntimeState = (): ReactiveTabRuntimeState => ({
stateContainer$: new BehaviorSubject<DiscoverStateContainer | undefined>(undefined),
customizationService$: new BehaviorSubject<ConnectedCustomizationService | undefined>(undefined),
unifiedHistogramLayoutProps$: new BehaviorSubject<UnifiedHistogramPartialLayoutProps | undefined>(
undefined
),
currentDataView$: new BehaviorSubject<DataView | undefined>(undefined),
});

View file

@ -7,9 +7,15 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { createSelector } from '@reduxjs/toolkit';
import type { DiscoverInternalState } from './types';
export const selectTab = (state: DiscoverInternalState, tabId: string) => state.tabs.byId[tabId];
export const selectAllTabs = (state: DiscoverInternalState) =>
state.tabs.allIds.map((id) => selectTab(state, id));
export const selectAllTabs = createSelector(
[
(state: DiscoverInternalState) => state.tabs.allIds,
(state: DiscoverInternalState) => state.tabs.byId,
],
(allIds, byId) => allIds.map((id) => byId[id])
);

View file

@ -11,7 +11,7 @@ import type { RefreshInterval } from '@kbn/data-plugin/common';
import type { DataViewListItem } from '@kbn/data-views-plugin/public';
import type { DataTableRecord } from '@kbn/discover-utils';
import type { Filter, TimeRange } from '@kbn/es-query';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram';
import type { TabItem } from '@kbn/unified-tabs';
export enum LoadingStatus {

Some files were not shown because too many files have changed in this diff Show more