mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Unified Histogram] Create layout container to manage Unified Histogram state (#148773)
## Summary This PR introduces a layout container component to Unified Histogram which removes the responsibility of state management from the consumer. The full list of changes includes the following: - Create a `UnifiedHistogramContainer` component which is responsible for managing the Unified Histogram state. - Create a `UnifiedHistogramStateService` to move state management from React to a dedicated service consumed by the container component. - Move the state management logic from `use_discover_histogram` to Unified Histogram so it doesn't need to be reimplemented by each consumer. - Create utility functions to access and update Unified Histogram local storage state. - Move the edit visualization logic to Unified Histogram so it doesn't need to be reimplemented by each consumer. - Add documentation and example usage to the Unified Histogram readme. - Reorganize the Unified Histogram folder structure. - Update `useQuerySubscriber` to return the relative time range. ### 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/packages/kbn-i18n/README.md)~ - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] ~Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))~ - [ ] ~Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))~ - [ ] ~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)~ - [ ] ~This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))~ - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Julia Rechkunova <julia.rechkunova@elastic.co> Co-authored-by: Julia Rechkunova <julia.rechkunova@gmail.com>
This commit is contained in:
parent
e143c8eaa6
commit
02af928026
64 changed files with 2445 additions and 795 deletions
|
@ -333,7 +333,8 @@ In general this plugin provides:
|
|||
|
||||
|
||||
|{kib-repo}blob/{branch}/src/plugins/unified_histogram/README.md[unifiedHistogram]
|
||||
|The unifiedHistogram plugin provides UI components to create a layout including a resizable histogram and a main display.
|
||||
|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.
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/src/plugins/unified_search/README.md[unifiedSearch]
|
||||
|
|
|
@ -109,6 +109,9 @@ export const buildDataViewMock = ({
|
|||
getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })),
|
||||
isTimeNanosBased: () => false,
|
||||
isPersisted: () => true,
|
||||
getTimeField: () => {
|
||||
return dataViewFields.find((field) => field.name === timeFieldName);
|
||||
},
|
||||
} as unknown as DataView;
|
||||
|
||||
dataView.isTimeBased = () => !!timeFieldName;
|
||||
|
|
|
@ -51,6 +51,10 @@ export function createDiscoverServicesMock(): DiscoverServices {
|
|||
dataPlugin.query.getState = jest.fn(() => ({
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
time: {
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
}));
|
||||
dataPlugin.dataViews = createDiscoverDataViewsMock();
|
||||
|
||||
|
|
|
@ -26,18 +26,15 @@ import { buildDataTableRecord } from '../../../../utils/build_data_record';
|
|||
import { DiscoverHistogramLayout, DiscoverHistogramLayoutProps } from './discover_histogram_layout';
|
||||
import { SavedSearch, VIEW_MODE } from '@kbn/saved-search-plugin/public';
|
||||
import { CoreTheme } from '@kbn/core/public';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import { LocalStorageMock } from '../../../../__mocks__/local_storage_mock';
|
||||
import { HISTOGRAM_HEIGHT_KEY } from './use_discover_histogram';
|
||||
import { createSearchSessionMock } from '../../../../__mocks__/search_session';
|
||||
import { RequestAdapter } from '@kbn/inspector-plugin/public';
|
||||
import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
|
||||
import { UnifiedHistogramLayout } from '@kbn/unified-histogram-plugin/public';
|
||||
import { getSessionServiceMock } from '@kbn/data-plugin/public/search/session/mocks';
|
||||
import { ResetSearchButton } from './reset_search_button';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { DiscoverMainProvider } from '../../services/discover_state_provider';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
function getStateContainer() {
|
||||
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
|
||||
|
@ -52,18 +49,19 @@ function getStateContainer() {
|
|||
return stateContainer;
|
||||
}
|
||||
|
||||
const mountComponent = ({
|
||||
const mountComponent = async ({
|
||||
isPlainRecord = false,
|
||||
isTimeBased = true,
|
||||
storage,
|
||||
savedSearch = savedSearchMock,
|
||||
resetSavedSearch = jest.fn(),
|
||||
searchSessionId = '123',
|
||||
}: {
|
||||
isPlainRecord?: boolean;
|
||||
isTimeBased?: boolean;
|
||||
storage?: Storage;
|
||||
savedSearch?: SavedSearch;
|
||||
resetSavedSearch?(): void;
|
||||
searchSessionId?: string | null;
|
||||
} = {}) => {
|
||||
let services = discoverServiceMock;
|
||||
services.data.query.timefilter.timefilter.getAbsoluteTime = () => {
|
||||
|
@ -114,7 +112,7 @@ const mountComponent = ({
|
|||
|
||||
const session = getSessionServiceMock();
|
||||
|
||||
session.getSession$.mockReturnValue(new BehaviorSubject('123'));
|
||||
session.getSession$.mockReturnValue(new BehaviorSubject(searchSessionId ?? undefined));
|
||||
|
||||
const stateContainer = getStateContainer();
|
||||
stateContainer.dataState.data$ = savedSearchData$;
|
||||
|
@ -131,7 +129,6 @@ const mountComponent = ({
|
|||
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
|
||||
onAddFilter: jest.fn(),
|
||||
resetSavedSearch,
|
||||
isTimeBased,
|
||||
resizeRef: { current: null },
|
||||
searchSessionManager: createSearchSessionMock(session).searchSessionManager,
|
||||
inspectorAdapters: { requests: new RequestAdapter() },
|
||||
|
@ -149,65 +146,39 @@ const mountComponent = ({
|
|||
</KibanaContextProvider>
|
||||
);
|
||||
|
||||
// wait for lazy modules
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
component.update();
|
||||
|
||||
return component;
|
||||
};
|
||||
|
||||
describe('Discover histogram layout component', () => {
|
||||
describe('topPanelHeight persistence', () => {
|
||||
it('should try to get the initial topPanelHeight for UnifiedHistogramLayout from storage', async () => {
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
const originalGet = storage.get;
|
||||
storage.get = jest.fn().mockImplementation(originalGet);
|
||||
mountComponent({ storage });
|
||||
expect(storage.get).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY);
|
||||
describe('render', () => {
|
||||
it('should render null if there is no search session', async () => {
|
||||
const component = await mountComponent({ searchSessionId: null });
|
||||
expect(component.isEmptyRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass undefined to UnifiedHistogramLayout if no value is found in storage', async () => {
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
const originalGet = storage.get;
|
||||
storage.get = jest.fn().mockImplementation(originalGet);
|
||||
const component = mountComponent({ storage });
|
||||
expect(storage.get).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY);
|
||||
expect(storage.get).toHaveReturnedWith(null);
|
||||
expect(component.find(UnifiedHistogramLayout).prop('topPanelHeight')).toBe(undefined);
|
||||
it('should not render null if there is a search session', async () => {
|
||||
const component = await mountComponent();
|
||||
expect(component.isEmptyRender()).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass the stored topPanelHeight to UnifiedHistogramLayout if a value is found in storage', async () => {
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
const topPanelHeight = 123;
|
||||
storage.get = jest.fn().mockImplementation(() => topPanelHeight);
|
||||
const component = mountComponent({ storage });
|
||||
expect(storage.get).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY);
|
||||
expect(storage.get).toHaveReturnedWith(topPanelHeight);
|
||||
expect(component.find(UnifiedHistogramLayout).prop('topPanelHeight')).toBe(topPanelHeight);
|
||||
});
|
||||
|
||||
it('should update the topPanelHeight in storage and pass the new value to UnifiedHistogramLayout when the topPanelHeight changes', async () => {
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
const originalSet = storage.set;
|
||||
storage.set = jest.fn().mockImplementation(originalSet);
|
||||
const component = mountComponent({ storage });
|
||||
const newTopPanelHeight = 123;
|
||||
expect(component.find(UnifiedHistogramLayout).prop('topPanelHeight')).not.toBe(
|
||||
newTopPanelHeight
|
||||
);
|
||||
act(() => {
|
||||
component.find(UnifiedHistogramLayout).prop('onTopPanelHeightChange')!(newTopPanelHeight);
|
||||
});
|
||||
component.update();
|
||||
expect(storage.set).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY, newTopPanelHeight);
|
||||
expect(component.find(UnifiedHistogramLayout).prop('topPanelHeight')).toBe(newTopPanelHeight);
|
||||
it('should not render null if there is no search session, but isPlainRecord is true', async () => {
|
||||
const component = await mountComponent({ isPlainRecord: true });
|
||||
expect(component.isEmptyRender()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset search button', () => {
|
||||
it('renders the button when there is a saved search', async () => {
|
||||
const component = mountComponent();
|
||||
const component = await mountComponent();
|
||||
expect(component.find(ResetSearchButton).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render the button when there is no saved search', async () => {
|
||||
const component = mountComponent({
|
||||
const component = await mountComponent({
|
||||
savedSearch: { ...savedSearchMock, id: undefined },
|
||||
});
|
||||
expect(component.find(ResetSearchButton).exists()).toBe(false);
|
||||
|
@ -215,7 +186,7 @@ describe('Discover histogram layout component', () => {
|
|||
|
||||
it('should call resetSavedSearch when clicked', async () => {
|
||||
const resetSavedSearch = jest.fn();
|
||||
const component = mountComponent({ resetSavedSearch });
|
||||
const component = await mountComponent({ resetSavedSearch });
|
||||
component.find(ResetSearchButton).find('button').simulate('click');
|
||||
expect(resetSavedSearch).toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -7,9 +7,9 @@
|
|||
*/
|
||||
|
||||
import React, { RefObject } from 'react';
|
||||
import { UnifiedHistogramLayout } from '@kbn/unified-histogram-plugin/public';
|
||||
import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public';
|
||||
import { css } from '@emotion/react';
|
||||
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { useDiscoverHistogram } from './use_discover_histogram';
|
||||
import type { DiscoverSearchSessionManager } from '../../services/discover_search_session';
|
||||
import type { InspectorAdapters } from '../../hooks/use_inspector';
|
||||
|
@ -18,68 +18,65 @@ import { ResetSearchButton } from './reset_search_button';
|
|||
|
||||
export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps {
|
||||
resetSavedSearch: () => void;
|
||||
isTimeBased: boolean;
|
||||
resizeRef: RefObject<HTMLDivElement>;
|
||||
inspectorAdapters: InspectorAdapters;
|
||||
searchSessionManager: DiscoverSearchSessionManager;
|
||||
}
|
||||
|
||||
const histogramLayoutCss = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const DiscoverHistogramLayout = ({
|
||||
isPlainRecord,
|
||||
dataView,
|
||||
resetSavedSearch,
|
||||
savedSearch,
|
||||
stateContainer,
|
||||
isTimeBased,
|
||||
resizeRef,
|
||||
inspectorAdapters,
|
||||
searchSessionManager,
|
||||
...mainContentProps
|
||||
}: DiscoverHistogramLayoutProps) => {
|
||||
const services = useDiscoverServices();
|
||||
|
||||
const commonProps = {
|
||||
dataView,
|
||||
isPlainRecord,
|
||||
stateContainer,
|
||||
savedSearch,
|
||||
savedSearchData$: stateContainer.dataState.data$,
|
||||
};
|
||||
|
||||
const histogramProps = useDiscoverHistogram({
|
||||
isTimeBased,
|
||||
const searchSessionId = useObservable(searchSessionManager.searchSessionId$);
|
||||
|
||||
const { hideChart, setUnifiedHistogramApi } = useDiscoverHistogram({
|
||||
inspectorAdapters,
|
||||
searchSessionManager,
|
||||
savedSearchFetch$: stateContainer.dataState.fetch$,
|
||||
searchSessionId,
|
||||
...commonProps,
|
||||
});
|
||||
|
||||
if (!histogramProps) {
|
||||
// Initialized when the first search has been requested or
|
||||
// when in text-based mode since search sessions are not supported
|
||||
if (!searchSessionId && !isPlainRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const histogramLayoutCss = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
return (
|
||||
<UnifiedHistogramLayout
|
||||
<UnifiedHistogramContainer
|
||||
ref={setUnifiedHistogramApi}
|
||||
resizeRef={resizeRef}
|
||||
services={services}
|
||||
dataView={dataView}
|
||||
appendHitsCounter={
|
||||
savedSearch?.id ? <ResetSearchButton resetSavedSearch={resetSavedSearch} /> : undefined
|
||||
}
|
||||
css={histogramLayoutCss}
|
||||
{...histogramProps}
|
||||
>
|
||||
<DiscoverMainContent
|
||||
{...commonProps}
|
||||
{...mainContentProps}
|
||||
savedSearch={savedSearch}
|
||||
isPlainRecord={isPlainRecord}
|
||||
// The documents grid doesn't rerender when the chart visibility changes
|
||||
// which causes it to render blank space, so we need to force a rerender
|
||||
key={`docKey${histogramProps.chart?.hidden}`}
|
||||
key={`docKey${hideChart}`}
|
||||
/>
|
||||
</UnifiedHistogramLayout>
|
||||
</UnifiedHistogramContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -40,10 +40,11 @@ import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'
|
|||
import { createSearchSessionMock } from '../../../../__mocks__/search_session';
|
||||
import { getSessionServiceMock } from '@kbn/data-plugin/public/search/session/mocks';
|
||||
import { DiscoverMainProvider } from '../../services/discover_state_provider';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
setHeaderActionMenuMounter(jest.fn());
|
||||
|
||||
function mountComponent(
|
||||
async function mountComponent(
|
||||
dataView: DataView,
|
||||
prevSidebarClosed?: boolean,
|
||||
mountOptions: { attachTo?: HTMLElement } = {},
|
||||
|
@ -57,13 +58,17 @@ function mountComponent(
|
|||
[SIDEBAR_CLOSED_KEY]: prevSidebarClosed,
|
||||
}) as unknown as Storage,
|
||||
} as unknown as DiscoverServices;
|
||||
services.data.query.timefilter.timefilter.getTime = () => {
|
||||
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
|
||||
};
|
||||
const time = { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
|
||||
services.data.query.timefilter.timefilter.getTime = () => time;
|
||||
(services.data.query.queryString.getDefaultQuery as jest.Mock).mockReturnValue({
|
||||
language: 'kuery',
|
||||
query: '',
|
||||
});
|
||||
(services.data.query.getState as jest.Mock).mockReturnValue({
|
||||
filters: [],
|
||||
query,
|
||||
time,
|
||||
});
|
||||
(searchSourceInstanceMock.fetch$ as jest.Mock).mockImplementation(
|
||||
jest.fn().mockReturnValue(of({ rawResponse: { hits: { total: 2 } } }))
|
||||
);
|
||||
|
@ -132,13 +137,19 @@ function mountComponent(
|
|||
mountOptions
|
||||
);
|
||||
|
||||
// wait for lazy modules
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
component.update();
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
describe('Discover component', () => {
|
||||
test('selected data view without time field displays no chart toggle', async () => {
|
||||
const container = document.createElement('div');
|
||||
mountComponent(dataViewMock, undefined, { attachTo: container });
|
||||
await mountComponent(dataViewMock, undefined, { attachTo: container });
|
||||
expect(
|
||||
container.querySelector('[data-test-subj="unifiedHistogramChartOptionsToggle"]')
|
||||
).toBeNull();
|
||||
|
@ -146,7 +157,7 @@ describe('Discover component', () => {
|
|||
|
||||
test('selected data view with time field displays chart toggle', async () => {
|
||||
const container = document.createElement('div');
|
||||
mountComponent(dataViewWithTimefieldMock, undefined, { attachTo: container });
|
||||
await mountComponent(dataViewWithTimefieldMock, undefined, { attachTo: container });
|
||||
expect(
|
||||
container.querySelector('[data-test-subj="unifiedHistogramChartOptionsToggle"]')
|
||||
).not.toBeNull();
|
||||
|
@ -154,7 +165,7 @@ describe('Discover component', () => {
|
|||
|
||||
test('sql query displays no chart toggle', async () => {
|
||||
const container = document.createElement('div');
|
||||
mountComponent(
|
||||
await mountComponent(
|
||||
dataViewWithTimefieldMock,
|
||||
false,
|
||||
{ attachTo: container },
|
||||
|
@ -169,7 +180,7 @@ describe('Discover component', () => {
|
|||
test('the saved search title h1 gains focus on navigate', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const component = mountComponent(dataViewWithTimefieldMock, undefined, {
|
||||
const component = await mountComponent(dataViewWithTimefieldMock, undefined, {
|
||||
attachTo: container,
|
||||
});
|
||||
expect(
|
||||
|
@ -179,17 +190,17 @@ describe('Discover component', () => {
|
|||
|
||||
describe('sidebar', () => {
|
||||
test('should be opened if discover:sidebarClosed was not set', async () => {
|
||||
const component = mountComponent(dataViewWithTimefieldMock, undefined);
|
||||
const component = await mountComponent(dataViewWithTimefieldMock, undefined);
|
||||
expect(component.find(DiscoverSidebar).length).toBe(1);
|
||||
}, 10000);
|
||||
|
||||
test('should be opened if discover:sidebarClosed is false', async () => {
|
||||
const component = mountComponent(dataViewWithTimefieldMock, false);
|
||||
const component = await mountComponent(dataViewWithTimefieldMock, false);
|
||||
expect(component.find(DiscoverSidebar).length).toBe(1);
|
||||
}, 10000);
|
||||
|
||||
test('should be closed if discover:sidebarClosed is true', async () => {
|
||||
const component = mountComponent(dataViewWithTimefieldMock, true);
|
||||
const component = await mountComponent(dataViewWithTimefieldMock, true);
|
||||
expect(component.find(DiscoverSidebar).length).toBe(0);
|
||||
}, 10000);
|
||||
});
|
||||
|
|
|
@ -239,7 +239,6 @@ export function DiscoverLayout({
|
|||
setExpandedDoc={setExpandedDoc}
|
||||
savedSearch={savedSearch}
|
||||
stateContainer={stateContainer}
|
||||
isTimeBased={isTimeBased}
|
||||
columns={currentColumns}
|
||||
viewMode={viewMode}
|
||||
onAddFilter={onAddFilter as DocViewFilterFn}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { ReactElement } from 'react';
|
||||
import { buildDataTableRecord } from '../../../../utils/build_data_record';
|
||||
import { esHits } from '../../../../__mocks__/es_hits';
|
||||
|
@ -20,51 +21,42 @@ import {
|
|||
RecordRawType,
|
||||
} from '../../services/discover_data_state_container';
|
||||
import type { DiscoverStateContainer } from '../../services/discover_state';
|
||||
import { savedSearchMock } from '../../../../__mocks__/saved_search';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import { LocalStorageMock } from '../../../../__mocks__/local_storage_mock';
|
||||
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
||||
import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield';
|
||||
import {
|
||||
CHART_HIDDEN_KEY,
|
||||
HISTOGRAM_HEIGHT_KEY,
|
||||
useDiscoverHistogram,
|
||||
UseDiscoverHistogramProps,
|
||||
} from './use_discover_histogram';
|
||||
import { useDiscoverHistogram, UseDiscoverHistogramProps } from './use_discover_histogram';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
import { calculateBounds } from '@kbn/data-plugin/public';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { DiscoverMainProvider } from '../../services/discover_state_provider';
|
||||
import { createSearchSessionMock } from '../../../../__mocks__/search_session';
|
||||
import { RequestAdapter } from '@kbn/inspector-plugin/public';
|
||||
import { getSessionServiceMock } from '@kbn/data-plugin/public/search/session/mocks';
|
||||
import { UnifiedHistogramFetchStatus } from '@kbn/unified-histogram-plugin/public';
|
||||
import {
|
||||
UnifiedHistogramFetchStatus,
|
||||
UnifiedHistogramInitializeOptions,
|
||||
UnifiedHistogramState,
|
||||
} from '@kbn/unified-histogram-plugin/public';
|
||||
import { createMockUnifiedHistogramApi } from '@kbn/unified-histogram-plugin/public/mocks';
|
||||
import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_messages';
|
||||
import type { InspectorAdapters } from '../../hooks/use_inspector';
|
||||
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
|
||||
import { DiscoverSearchSessionManager } from '../../services/discover_search_session';
|
||||
|
||||
const mockData = dataPluginMock.createStartContract();
|
||||
|
||||
mockData.query.timefilter.timefilter.getTime = () => {
|
||||
return { from: '1991-03-29T08:04:00.694Z', to: '2021-03-29T07:04:00.695Z' };
|
||||
};
|
||||
mockData.query.timefilter.timefilter.calculateBounds = (timeRange) => {
|
||||
return calculateBounds(timeRange);
|
||||
const mockQueryState = {
|
||||
query: {
|
||||
query: 'query',
|
||||
language: 'kuery',
|
||||
},
|
||||
filters: [],
|
||||
time: {
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
};
|
||||
|
||||
const mockLens = {
|
||||
navigateToPrefilledEditor: jest.fn(),
|
||||
};
|
||||
|
||||
let mockStorage = new LocalStorageMock({}) as unknown as Storage;
|
||||
let mockCanVisualize = true;
|
||||
mockData.query.getState = () => mockQueryState;
|
||||
|
||||
jest.mock('../../../../hooks/use_discover_services', () => {
|
||||
const originalModule = jest.requireActual('../../../../hooks/use_discover_services');
|
||||
return {
|
||||
...originalModule,
|
||||
useDiscoverServices: () => ({ storage: mockStorage, data: mockData, lens: mockLens }),
|
||||
useDiscoverServices: () => ({ data: mockData }),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -72,34 +64,14 @@ jest.mock('@kbn/unified-field-list-plugin/public', () => {
|
|||
const originalModule = jest.requireActual('@kbn/unified-field-list-plugin/public');
|
||||
return {
|
||||
...originalModule,
|
||||
getVisualizeInformation: jest.fn(() => Promise.resolve(mockCanVisualize)),
|
||||
useQuerySubscriber: jest.fn(() => ({
|
||||
query: {
|
||||
query: 'query',
|
||||
language: 'kuery',
|
||||
},
|
||||
filters: [],
|
||||
...mockQueryState,
|
||||
fromDate: 'now-15m',
|
||||
toDate: 'now',
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
function getStateContainer() {
|
||||
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
|
||||
|
||||
stateContainer.setAppState({
|
||||
interval: 'auto',
|
||||
hideChart: false,
|
||||
breakdownField: 'extension',
|
||||
});
|
||||
|
||||
const wrappedStateContainer = Object.create(stateContainer);
|
||||
wrappedStateContainer.setAppState = jest.fn((newState) => stateContainer.setAppState(newState));
|
||||
|
||||
return wrappedStateContainer;
|
||||
}
|
||||
|
||||
jest.mock('../../hooks/use_saved_search_messages', () => {
|
||||
const originalModule = jest.requireActual('../../hooks/use_saved_search_messages');
|
||||
return {
|
||||
|
@ -112,13 +84,20 @@ jest.mock('../../hooks/use_saved_search_messages', () => {
|
|||
const mockCheckHitCount = checkHitCount as jest.MockedFunction<typeof checkHitCount>;
|
||||
|
||||
describe('useDiscoverHistogram', () => {
|
||||
const getStateContainer = () => {
|
||||
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
|
||||
stateContainer.setAppState({
|
||||
interval: 'auto',
|
||||
hideChart: false,
|
||||
breakdownField: 'extension',
|
||||
});
|
||||
const wrappedStateContainer = Object.create(stateContainer);
|
||||
wrappedStateContainer.setAppState = jest.fn((newState) => stateContainer.setAppState(newState));
|
||||
return wrappedStateContainer;
|
||||
};
|
||||
|
||||
const renderUseDiscoverHistogram = async ({
|
||||
isPlainRecord = false,
|
||||
isTimeBased = true,
|
||||
canVisualize = true,
|
||||
storage = new LocalStorageMock({}) as unknown as Storage,
|
||||
stateContainer = getStateContainer(),
|
||||
searchSessionManager,
|
||||
searchSessionId = '123',
|
||||
inspectorAdapters = { requests: new RequestAdapter() },
|
||||
totalHits$ = new BehaviorSubject({
|
||||
|
@ -127,26 +106,18 @@ describe('useDiscoverHistogram', () => {
|
|||
}) as DataTotalHits$,
|
||||
main$ = new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
recordRawType: isPlainRecord ? RecordRawType.PLAIN : RecordRawType.DOCUMENT,
|
||||
recordRawType: RecordRawType.DOCUMENT,
|
||||
foundDocuments: true,
|
||||
}) as DataMain$,
|
||||
savedSearchFetch$ = new Subject() as DataFetch$,
|
||||
}: {
|
||||
isPlainRecord?: boolean;
|
||||
isTimeBased?: boolean;
|
||||
canVisualize?: boolean;
|
||||
storage?: Storage;
|
||||
stateContainer?: DiscoverStateContainer;
|
||||
searchSessionManager?: DiscoverSearchSessionManager;
|
||||
searchSessionId?: string | null;
|
||||
searchSessionId?: string;
|
||||
inspectorAdapters?: InspectorAdapters;
|
||||
totalHits$?: DataTotalHits$;
|
||||
main$?: DataMain$;
|
||||
savedSearchFetch$?: DataFetch$;
|
||||
} = {}) => {
|
||||
mockStorage = storage;
|
||||
mockCanVisualize = canVisualize;
|
||||
|
||||
const documents$ = new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
result: esHits.map((esHit) => buildDataTableRecord(esHit, dataViewWithTimefieldMock)),
|
||||
|
@ -164,22 +135,13 @@ describe('useDiscoverHistogram', () => {
|
|||
availableFields$,
|
||||
};
|
||||
|
||||
if (!searchSessionManager) {
|
||||
const session = getSessionServiceMock();
|
||||
session.getSession$.mockReturnValue(new BehaviorSubject(searchSessionId ?? undefined));
|
||||
searchSessionManager = createSearchSessionMock(session).searchSessionManager;
|
||||
}
|
||||
|
||||
const initialProps = {
|
||||
stateContainer,
|
||||
savedSearchData$,
|
||||
savedSearchFetch$,
|
||||
dataView: dataViewWithTimefieldMock,
|
||||
savedSearch: savedSearchMock,
|
||||
isTimeBased,
|
||||
isPlainRecord,
|
||||
inspectorAdapters,
|
||||
searchSessionManager: searchSessionManager!,
|
||||
searchSessionId,
|
||||
};
|
||||
|
||||
const Wrapper: WrapperComponent<UseDiscoverHistogramProps> = ({ children }) => (
|
||||
|
@ -201,262 +163,188 @@ describe('useDiscoverHistogram', () => {
|
|||
return { hook, initialProps };
|
||||
};
|
||||
|
||||
describe('result', () => {
|
||||
it('should return undefined if there is no search session', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({ searchSessionId: null });
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('it should not return undefined if there is no search session, but isPlainRecord is true', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({ searchSessionId: null, isPlainRecord: true });
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contexts', () => {
|
||||
it('should output the correct hits context', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram();
|
||||
expect(result.current?.hits?.status).toBe(UnifiedHistogramFetchStatus.complete);
|
||||
expect(result.current?.hits?.total).toEqual(esHits.length);
|
||||
});
|
||||
|
||||
it('should output the correct chart context', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram();
|
||||
expect(result.current?.chart?.hidden).toBe(false);
|
||||
expect(result.current?.chart?.timeInterval).toBe('auto');
|
||||
});
|
||||
|
||||
it('should output the correct breakdown context', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram();
|
||||
expect(result.current?.breakdown?.field?.name).toBe('extension');
|
||||
});
|
||||
|
||||
it('should output the correct request context', async () => {
|
||||
const requestAdapter = new RequestAdapter();
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({
|
||||
searchSessionId: '321',
|
||||
inspectorAdapters: { requests: requestAdapter },
|
||||
});
|
||||
expect(result.current?.request.adapter).toBe(requestAdapter);
|
||||
expect(result.current?.request.searchSessionId).toBe('321');
|
||||
});
|
||||
|
||||
it('should output undefined for hits and chart and breakdown if isPlainRecord is true', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({ isPlainRecord: true });
|
||||
expect(result.current?.hits).toBeUndefined();
|
||||
expect(result.current?.chart).toBeUndefined();
|
||||
expect(result.current?.breakdown).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should output undefined for chart and breakdown if isTimeBased is false', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({ isTimeBased: false });
|
||||
expect(result.current?.hits).not.toBeUndefined();
|
||||
expect(result.current?.chart).toBeUndefined();
|
||||
expect(result.current?.breakdown).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear lensRequests when chart is undefined', async () => {
|
||||
const inspectorAdapters = {
|
||||
requests: new RequestAdapter(),
|
||||
lensRequests: new RequestAdapter(),
|
||||
};
|
||||
const { hook, initialProps } = await renderUseDiscoverHistogram({
|
||||
inspectorAdapters,
|
||||
});
|
||||
expect(inspectorAdapters.lensRequests).toBeDefined();
|
||||
hook.rerender({ ...initialProps, isPlainRecord: true });
|
||||
expect(inspectorAdapters.lensRequests).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search params', () => {
|
||||
it('should return the correct query, filters, and timeRange', async () => {
|
||||
describe('initialization', () => {
|
||||
it('should pass the expected parameters to initialize', async () => {
|
||||
const { hook } = await renderUseDiscoverHistogram();
|
||||
expect(hook.result.current?.query).toEqual({
|
||||
query: 'query',
|
||||
language: 'kuery',
|
||||
const api = createMockUnifiedHistogramApi();
|
||||
let params: UnifiedHistogramInitializeOptions | undefined;
|
||||
api.initialize = jest.fn((p) => {
|
||||
params = p;
|
||||
});
|
||||
expect(hook.result.current?.filters).toEqual([]);
|
||||
expect(hook.result.current?.timeRange).toEqual({
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
act(() => {
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
expect(api.initialize).toHaveBeenCalled();
|
||||
expect(params?.localStorageKeyPrefix).toBe('discover');
|
||||
expect(params?.disableAutoFetching).toBe(true);
|
||||
expect(Object.keys(params?.initialState ?? {})).toEqual([
|
||||
'dataView',
|
||||
'query',
|
||||
'filters',
|
||||
'timeRange',
|
||||
'chartHidden',
|
||||
'timeInterval',
|
||||
'breakdownField',
|
||||
'searchSessionId',
|
||||
'totalHitsStatus',
|
||||
'totalHitsResult',
|
||||
'requestAdapter',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onEditVisualization', () => {
|
||||
it('returns a callback for onEditVisualization when the data view can be visualized', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram();
|
||||
expect(result.current?.onEditVisualization).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns undefined for onEditVisualization when the data view cannot be visualized', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({ canVisualize: false });
|
||||
expect(result.current?.onEditVisualization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call lens.navigateToPrefilledEditor when onEditVisualization is called', async () => {
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram();
|
||||
const attributes = { title: 'test' } as TypedLensByValueInput['attributes'];
|
||||
result.current?.onEditVisualization!(attributes);
|
||||
expect(mockLens.navigateToPrefilledEditor).toHaveBeenCalledWith({
|
||||
id: '',
|
||||
timeRange: mockData.query.timefilter.timefilter.getTime(),
|
||||
attributes,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('topPanelHeight', () => {
|
||||
it('should try to get the topPanelHeight from storage', async () => {
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
storage.get = jest.fn(() => 100);
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({ storage });
|
||||
expect(storage.get).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY);
|
||||
expect(result.current?.topPanelHeight).toBe(100);
|
||||
});
|
||||
|
||||
it('should update topPanelHeight when onTopPanelHeightChange is called', async () => {
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
storage.get = jest.fn(() => 100);
|
||||
storage.set = jest.fn();
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({ storage });
|
||||
expect(result.current?.topPanelHeight).toBe(100);
|
||||
describe('state', () => {
|
||||
it('should subscribe to state changes', async () => {
|
||||
const { hook } = await renderUseDiscoverHistogram();
|
||||
const api = createMockUnifiedHistogramApi({ initialized: true });
|
||||
jest.spyOn(api.state$, 'subscribe');
|
||||
act(() => {
|
||||
result.current?.onTopPanelHeightChange(200);
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
expect(storage.set).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY, 200);
|
||||
expect(result.current?.topPanelHeight).toBe(200);
|
||||
expect(api.state$.subscribe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('should update chartHidden when onChartHiddenChange is called', async () => {
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
storage.set = jest.fn();
|
||||
it('should sync Unified Histogram state with the state container', async () => {
|
||||
const stateContainer = getStateContainer();
|
||||
const session = getSessionServiceMock();
|
||||
const session$ = new BehaviorSubject('123');
|
||||
session.getSession$.mockReturnValue(session$);
|
||||
const inspectorAdapters = {
|
||||
requests: new RequestAdapter(),
|
||||
lensRequests: new RequestAdapter(),
|
||||
};
|
||||
const { hook } = await renderUseDiscoverHistogram({
|
||||
storage,
|
||||
stateContainer,
|
||||
searchSessionManager: createSearchSessionMock(session).searchSessionManager,
|
||||
inspectorAdapters,
|
||||
});
|
||||
const inspectorAdapters = { requests: new RequestAdapter(), lensRequests: undefined };
|
||||
const { hook } = await renderUseDiscoverHistogram({ stateContainer, inspectorAdapters });
|
||||
const lensRequestAdapter = new RequestAdapter();
|
||||
const state = {
|
||||
timeInterval: '1m',
|
||||
chartHidden: true,
|
||||
breakdownField: 'test',
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
|
||||
totalHitsResult: undefined,
|
||||
} as unknown as UnifiedHistogramState;
|
||||
const api = createMockUnifiedHistogramApi({ initialized: true });
|
||||
api.state$ = new BehaviorSubject({ ...state, lensRequestAdapter });
|
||||
act(() => {
|
||||
hook.result.current?.onChartHiddenChange(false);
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
expect(inspectorAdapters.lensRequests).toBeDefined();
|
||||
expect(storage.set).toHaveBeenCalledWith(CHART_HIDDEN_KEY, false);
|
||||
expect(stateContainer.setAppState).toHaveBeenCalledWith({ hideChart: false });
|
||||
act(() => {
|
||||
hook.result.current?.onChartHiddenChange(true);
|
||||
session$.next('321');
|
||||
expect(inspectorAdapters.lensRequests).toBe(lensRequestAdapter);
|
||||
expect(stateContainer.setAppState).toHaveBeenCalledWith({
|
||||
interval: state.timeInterval,
|
||||
hideChart: state.chartHidden,
|
||||
breakdownField: state.breakdownField,
|
||||
});
|
||||
hook.rerender();
|
||||
expect(inspectorAdapters.lensRequests).toBeUndefined();
|
||||
expect(storage.set).toHaveBeenCalledWith(CHART_HIDDEN_KEY, true);
|
||||
expect(stateContainer.setAppState).toHaveBeenCalledWith({ hideChart: true });
|
||||
});
|
||||
|
||||
it('should set lensRequests when onChartLoad is called', async () => {
|
||||
const lensRequests = new RequestAdapter();
|
||||
const inspectorAdapters = {
|
||||
requests: new RequestAdapter(),
|
||||
lensRequests: undefined as RequestAdapter | undefined,
|
||||
};
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({ inspectorAdapters });
|
||||
expect(inspectorAdapters.lensRequests).toBeUndefined();
|
||||
act(() => {
|
||||
result.current?.onChartLoad({ adapters: { requests: lensRequests } });
|
||||
});
|
||||
expect(inspectorAdapters.lensRequests).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update chart hidden when onChartHiddenChange is called', async () => {
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
storage.set = jest.fn();
|
||||
it('should not sync Unified Histogram state with the state container if there are no changes', async () => {
|
||||
const stateContainer = getStateContainer();
|
||||
const inspectorAdapters = {
|
||||
requests: new RequestAdapter(),
|
||||
lensRequests: new RequestAdapter(),
|
||||
};
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({
|
||||
storage,
|
||||
stateContainer,
|
||||
inspectorAdapters,
|
||||
});
|
||||
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
|
||||
const containerState = stateContainer.appState.getState();
|
||||
const state = {
|
||||
timeInterval: containerState.interval,
|
||||
chartHidden: containerState.hideChart,
|
||||
breakdownField: containerState.breakdownField,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
|
||||
totalHitsResult: undefined,
|
||||
} as unknown as UnifiedHistogramState;
|
||||
const api = createMockUnifiedHistogramApi({ initialized: true });
|
||||
api.state$ = new BehaviorSubject(state);
|
||||
act(() => {
|
||||
result.current?.onChartHiddenChange(true);
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
expect(storage.set).toHaveBeenCalledWith(CHART_HIDDEN_KEY, true);
|
||||
expect(stateContainer.setAppState).toHaveBeenCalledWith({ hideChart: true });
|
||||
expect(stateContainer.setAppState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update interval when onTimeIntervalChange is called', async () => {
|
||||
it('should sync the state container state with Unified Histogram', async () => {
|
||||
const stateContainer = getStateContainer();
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({
|
||||
stateContainer,
|
||||
const { hook } = await renderUseDiscoverHistogram({ stateContainer });
|
||||
const api = createMockUnifiedHistogramApi({ initialized: true });
|
||||
let params: Partial<UnifiedHistogramState> = {};
|
||||
api.setRequestParams = jest.fn((p) => {
|
||||
params = { ...params, ...p };
|
||||
});
|
||||
api.setTotalHits = jest.fn((p) => {
|
||||
params = { ...params, ...p };
|
||||
});
|
||||
api.setChartHidden = jest.fn((chartHidden) => {
|
||||
params = { ...params, chartHidden };
|
||||
});
|
||||
api.setTimeInterval = jest.fn((timeInterval) => {
|
||||
params = { ...params, timeInterval };
|
||||
});
|
||||
api.setBreakdownField = jest.fn((breakdownField) => {
|
||||
params = { ...params, breakdownField };
|
||||
});
|
||||
act(() => {
|
||||
result.current?.onTimeIntervalChange('auto');
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
expect(stateContainer.setAppState).toHaveBeenCalledWith({ interval: 'auto' });
|
||||
expect(api.setRequestParams).toHaveBeenCalled();
|
||||
expect(api.setTotalHits).toHaveBeenCalled();
|
||||
expect(api.setChartHidden).toHaveBeenCalled();
|
||||
expect(api.setTimeInterval).toHaveBeenCalled();
|
||||
expect(api.setBreakdownField).toHaveBeenCalled();
|
||||
expect(Object.keys(params ?? {})).toEqual([
|
||||
'dataView',
|
||||
'query',
|
||||
'filters',
|
||||
'timeRange',
|
||||
'searchSessionId',
|
||||
'requestAdapter',
|
||||
'totalHitsStatus',
|
||||
'totalHitsResult',
|
||||
'chartHidden',
|
||||
'timeInterval',
|
||||
'breakdownField',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update breakdownField when onBreakdownFieldChange is called', async () => {
|
||||
it('should exclude totalHitsStatus and totalHitsResult from Unified Histogram state updates after the first load', async () => {
|
||||
const stateContainer = getStateContainer();
|
||||
const {
|
||||
hook: { result },
|
||||
} = await renderUseDiscoverHistogram({
|
||||
stateContainer,
|
||||
const { hook, initialProps } = await renderUseDiscoverHistogram({ stateContainer });
|
||||
const containerState = stateContainer.appState.getState();
|
||||
const state = {
|
||||
timeInterval: containerState.interval,
|
||||
chartHidden: containerState.hideChart,
|
||||
breakdownField: containerState.breakdownField,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
|
||||
totalHitsResult: undefined,
|
||||
} as unknown as UnifiedHistogramState;
|
||||
const api = createMockUnifiedHistogramApi({ initialized: true });
|
||||
let params: Partial<UnifiedHistogramState> = {};
|
||||
api.setRequestParams = jest.fn((p) => {
|
||||
params = { ...params, ...p };
|
||||
});
|
||||
api.setTotalHits = jest.fn((p) => {
|
||||
params = { ...params, ...p };
|
||||
});
|
||||
const subject$ = new BehaviorSubject(state);
|
||||
api.state$ = subject$;
|
||||
act(() => {
|
||||
result.current?.onBreakdownFieldChange(
|
||||
dataViewWithTimefieldMock.getFieldByName('extension')
|
||||
);
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
expect(stateContainer.setAppState).toHaveBeenCalledWith({ breakdownField: 'extension' });
|
||||
expect(Object.keys(params ?? {})).toEqual([
|
||||
'dataView',
|
||||
'query',
|
||||
'filters',
|
||||
'timeRange',
|
||||
'searchSessionId',
|
||||
'requestAdapter',
|
||||
'totalHitsStatus',
|
||||
'totalHitsResult',
|
||||
]);
|
||||
params = {};
|
||||
hook.rerender({ ...initialProps, searchSessionId: '321' });
|
||||
act(() => {
|
||||
subject$.next({
|
||||
...state,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.complete,
|
||||
totalHitsResult: 100,
|
||||
});
|
||||
});
|
||||
expect(Object.keys(params ?? {})).toEqual([
|
||||
'dataView',
|
||||
'query',
|
||||
'filters',
|
||||
'timeRange',
|
||||
'searchSessionId',
|
||||
'requestAdapter',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update total hits when onTotalHitsChange is called', async () => {
|
||||
it('should update total hits when the total hits state changes', async () => {
|
||||
mockCheckHitCount.mockClear();
|
||||
const totalHits$ = new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.LOADING,
|
||||
|
@ -467,13 +355,25 @@ describe('useDiscoverHistogram', () => {
|
|||
recordRawType: RecordRawType.DOCUMENT,
|
||||
foundDocuments: true,
|
||||
}) as DataMain$;
|
||||
const { hook } = await renderUseDiscoverHistogram({ totalHits$, main$ });
|
||||
act(() => {
|
||||
hook.result.current?.onTotalHitsChange(UnifiedHistogramFetchStatus.complete, 100);
|
||||
const stateContainer = getStateContainer();
|
||||
const { hook } = await renderUseDiscoverHistogram({ stateContainer, totalHits$, main$ });
|
||||
const containerState = stateContainer.appState.getState();
|
||||
const state = {
|
||||
timeInterval: containerState.interval,
|
||||
chartHidden: containerState.hideChart,
|
||||
breakdownField: containerState.breakdownField,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
|
||||
totalHitsResult: undefined,
|
||||
} as unknown as UnifiedHistogramState;
|
||||
const api = createMockUnifiedHistogramApi({ initialized: true });
|
||||
api.state$ = new BehaviorSubject({
|
||||
...state,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.complete,
|
||||
totalHitsResult: 100,
|
||||
});
|
||||
act(() => {
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
hook.rerender();
|
||||
expect(hook.result.current?.hits?.status).toBe(UnifiedHistogramFetchStatus.complete);
|
||||
expect(hook.result.current?.hits?.total).toBe(100);
|
||||
expect(totalHits$.value).toEqual({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
result: 100,
|
||||
|
@ -481,105 +381,80 @@ describe('useDiscoverHistogram', () => {
|
|||
expect(mockCheckHitCount).toHaveBeenCalledWith(main$, 100);
|
||||
});
|
||||
|
||||
it('should not update total hits when onTotalHitsChange is called with an error', async () => {
|
||||
it('should not update total hits when the total hits state changes to an error', async () => {
|
||||
mockCheckHitCount.mockClear();
|
||||
const totalHits$ = new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.UNINITIALIZED,
|
||||
result: undefined,
|
||||
}) as DataTotalHits$;
|
||||
const { hook } = await renderUseDiscoverHistogram({ totalHits$ });
|
||||
const stateContainer = getStateContainer();
|
||||
const { hook } = await renderUseDiscoverHistogram({ stateContainer, totalHits$ });
|
||||
const containerState = stateContainer.appState.getState();
|
||||
const error = new Error('test');
|
||||
act(() => {
|
||||
hook.result.current?.onTotalHitsChange(UnifiedHistogramFetchStatus.error, error);
|
||||
const state = {
|
||||
timeInterval: containerState.interval,
|
||||
chartHidden: containerState.hideChart,
|
||||
breakdownField: containerState.breakdownField,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
|
||||
totalHitsResult: undefined,
|
||||
} as unknown as UnifiedHistogramState;
|
||||
const api = createMockUnifiedHistogramApi({ initialized: true });
|
||||
api.state$ = new BehaviorSubject({
|
||||
...state,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.error,
|
||||
totalHitsResult: error,
|
||||
});
|
||||
act(() => {
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
hook.rerender();
|
||||
expect(sendErrorTo).toHaveBeenCalledWith(mockData, totalHits$);
|
||||
expect(hook.result.current?.hits?.status).toBe(UnifiedHistogramFetchStatus.error);
|
||||
expect(hook.result.current?.hits?.total).toBeUndefined();
|
||||
expect(totalHits$.value).toEqual({
|
||||
fetchStatus: FetchStatus.ERROR,
|
||||
error,
|
||||
});
|
||||
expect(mockCheckHitCount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update total hits when onTotalHitsChange is called with a loading status while totalHits$ has a partial status', async () => {
|
||||
mockCheckHitCount.mockClear();
|
||||
const totalHits$ = new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.PARTIAL,
|
||||
result: undefined,
|
||||
}) as DataTotalHits$;
|
||||
const { hook } = await renderUseDiscoverHistogram({ totalHits$ });
|
||||
act(() => {
|
||||
hook.result.current?.onTotalHitsChange(UnifiedHistogramFetchStatus.loading, undefined);
|
||||
});
|
||||
hook.rerender();
|
||||
expect(hook.result.current?.hits?.status).toBe(UnifiedHistogramFetchStatus.partial);
|
||||
expect(hook.result.current?.hits?.total).toBeUndefined();
|
||||
expect(totalHits$.value).toEqual({
|
||||
fetchStatus: FetchStatus.PARTIAL,
|
||||
result: undefined,
|
||||
});
|
||||
expect(mockCheckHitCount).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refetching', () => {
|
||||
it("should call input$.next({ type: 'refetch' }) when savedSearchFetch$ is triggered", async () => {
|
||||
const savedSearchFetch$ = new BehaviorSubject({ reset: false, searchSessionId: '1234' });
|
||||
it('should call refetch when savedSearchFetch$ is triggered', async () => {
|
||||
const savedSearchFetch$ = new Subject<{
|
||||
reset: boolean;
|
||||
searchSessionId: string;
|
||||
}>();
|
||||
const { hook } = await renderUseDiscoverHistogram({ savedSearchFetch$ });
|
||||
const onRefetch = jest.fn();
|
||||
hook.result.current?.input$.subscribe(onRefetch);
|
||||
const api = createMockUnifiedHistogramApi({ initialized: true });
|
||||
act(() => {
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
expect(api.refetch).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
savedSearchFetch$.next({ reset: false, searchSessionId: '1234' });
|
||||
});
|
||||
expect(onRefetch).toHaveBeenCalledWith({ type: 'refetch' });
|
||||
expect(api.refetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call input$.next({ type: 'refetch' }) when searchSessionId is not set", async () => {
|
||||
const savedSearchFetch$ = new BehaviorSubject({ reset: false, searchSessionId: '1234' });
|
||||
const { hook } = await renderUseDiscoverHistogram({
|
||||
savedSearchFetch$,
|
||||
searchSessionId: null,
|
||||
});
|
||||
const onRefetch = jest.fn();
|
||||
hook.result.current?.input$.subscribe(onRefetch);
|
||||
it('should skip the next refetch when hideChart changes from true to false', async () => {
|
||||
const stateContainer = getStateContainer();
|
||||
const savedSearchFetch$ = new Subject<{
|
||||
reset: boolean;
|
||||
searchSessionId: string;
|
||||
}>();
|
||||
const { hook } = await renderUseDiscoverHistogram({ stateContainer, savedSearchFetch$ });
|
||||
const api = createMockUnifiedHistogramApi({ initialized: true });
|
||||
act(() => {
|
||||
savedSearchFetch$.next({ reset: false, searchSessionId: '1234' });
|
||||
});
|
||||
expect(onRefetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call input$.next({ type: 'refetch' }) when searchSessionId is not set and isPlainRecord is true", async () => {
|
||||
const savedSearchFetch$ = new BehaviorSubject({ reset: false, searchSessionId: '1234' });
|
||||
const { hook } = await renderUseDiscoverHistogram({
|
||||
savedSearchFetch$,
|
||||
searchSessionId: null,
|
||||
isPlainRecord: true,
|
||||
});
|
||||
const onRefetch = jest.fn();
|
||||
hook.result.current?.input$.subscribe(onRefetch);
|
||||
act(() => {
|
||||
savedSearchFetch$.next({ reset: false, searchSessionId: '1234' });
|
||||
});
|
||||
expect(onRefetch).toHaveBeenCalledWith({ type: 'refetch' });
|
||||
});
|
||||
|
||||
it('should skip the next refetch when state.hideChart changes from true to false', async () => {
|
||||
const savedSearchFetch$ = new BehaviorSubject({ reset: false, searchSessionId: '1234' });
|
||||
const { hook } = await renderUseDiscoverHistogram({ savedSearchFetch$ });
|
||||
const onRefetch = jest.fn();
|
||||
hook.result.current?.input$.subscribe(onRefetch);
|
||||
act(() => {
|
||||
hook.result.current?.onChartHiddenChange(true);
|
||||
hook.result.current.setUnifiedHistogramApi(api);
|
||||
});
|
||||
act(() => {
|
||||
hook.result.current?.onChartHiddenChange(false);
|
||||
stateContainer.setAppState({ hideChart: true });
|
||||
});
|
||||
act(() => {
|
||||
stateContainer.setAppState({ hideChart: false });
|
||||
});
|
||||
act(() => {
|
||||
savedSearchFetch$.next({ reset: false, searchSessionId: '1234' });
|
||||
});
|
||||
expect(onRefetch).not.toHaveBeenCalled();
|
||||
expect(api.refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,277 +6,258 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
|
||||
import { getVisualizeInformation, useQuerySubscriber } from '@kbn/unified-field-list-plugin/public';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { useQuerySubscriber } from '@kbn/unified-field-list-plugin/public';
|
||||
import {
|
||||
UnifiedHistogramApi,
|
||||
UnifiedHistogramFetchStatus,
|
||||
UnifiedHistogramHitsContext,
|
||||
UnifiedHistogramInputMessage,
|
||||
UnifiedHistogramInitializedApi,
|
||||
UnifiedHistogramState,
|
||||
} from '@kbn/unified-histogram-plugin/public';
|
||||
import type { UnifiedHistogramChartLoadEvent } from '@kbn/unified-histogram-plugin/public';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
|
||||
import { Subject } from 'rxjs';
|
||||
import { useAppStateSelector } from '../../services/discover_app_state_container';
|
||||
import { getUiActions } from '../../../../kibana_services';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { distinctUntilChanged, map, Observable } from 'rxjs';
|
||||
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
|
||||
import { useDataState } from '../../hooks/use_data_state';
|
||||
import type { DataFetch$, SavedSearchData } from '../../services/discover_data_state_container';
|
||||
import type { DiscoverStateContainer } from '../../services/discover_state';
|
||||
import { getUiActions } from '../../../../kibana_services';
|
||||
import { FetchStatus } from '../../../types';
|
||||
import type { DiscoverSearchSessionManager } from '../../services/discover_search_session';
|
||||
import { useDataState } from '../../hooks/use_data_state';
|
||||
import type { InspectorAdapters } from '../../hooks/use_inspector';
|
||||
import type { DataFetch$, SavedSearchData } from '../../services/discover_data_state_container';
|
||||
import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_messages';
|
||||
|
||||
export const CHART_HIDDEN_KEY = 'discover:chartHidden';
|
||||
export const HISTOGRAM_HEIGHT_KEY = 'discover:histogramHeight';
|
||||
export const HISTOGRAM_BREAKDOWN_FIELD_KEY = 'discover:histogramBreakdownField';
|
||||
import { useAppStateSelector } from '../../services/discover_app_state_container';
|
||||
import type { DiscoverStateContainer } from '../../services/discover_state';
|
||||
|
||||
export interface UseDiscoverHistogramProps {
|
||||
stateContainer: DiscoverStateContainer;
|
||||
savedSearchData$: SavedSearchData;
|
||||
dataView: DataView;
|
||||
savedSearch: SavedSearch;
|
||||
isTimeBased: boolean;
|
||||
isPlainRecord: boolean;
|
||||
inspectorAdapters: InspectorAdapters;
|
||||
searchSessionManager: DiscoverSearchSessionManager;
|
||||
savedSearchFetch$: DataFetch$;
|
||||
searchSessionId: string | undefined;
|
||||
}
|
||||
|
||||
export const useDiscoverHistogram = ({
|
||||
stateContainer,
|
||||
savedSearchData$,
|
||||
dataView,
|
||||
savedSearch,
|
||||
isTimeBased,
|
||||
isPlainRecord,
|
||||
inspectorAdapters,
|
||||
searchSessionManager,
|
||||
savedSearchFetch$,
|
||||
searchSessionId,
|
||||
}: UseDiscoverHistogramProps) => {
|
||||
const { storage, data, lens } = useDiscoverServices();
|
||||
const [hideChart, interval, breakdownField] = useAppStateSelector((state) => [
|
||||
state.hideChart,
|
||||
state.interval,
|
||||
state.breakdownField,
|
||||
const services = useDiscoverServices();
|
||||
const timefilter = services.data.query.timefilter.timefilter;
|
||||
|
||||
/**
|
||||
* API initialization
|
||||
*/
|
||||
|
||||
const [unifiedHistogram, setUnifiedHistogram] = useState<UnifiedHistogramInitializedApi>();
|
||||
|
||||
const setUnifiedHistogramApi = useCallback(
|
||||
(api: UnifiedHistogramApi | null) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (api.initialized) {
|
||||
setUnifiedHistogram(api);
|
||||
} else {
|
||||
const {
|
||||
hideChart: chartHidden,
|
||||
interval: timeInterval,
|
||||
breakdownField,
|
||||
} = stateContainer.appState.getState();
|
||||
|
||||
const { fetchStatus: totalHitsStatus, result: totalHitsResult } =
|
||||
savedSearchData$.totalHits$.getValue();
|
||||
|
||||
const { query, filters, time: timeRange } = services.data.query.getState();
|
||||
|
||||
api.initialize({
|
||||
services: { ...services, uiActions: getUiActions() },
|
||||
localStorageKeyPrefix: 'discover',
|
||||
disableAutoFetching: true,
|
||||
getRelativeTimeRange: timefilter.getTime,
|
||||
initialState: {
|
||||
dataView,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
chartHidden,
|
||||
timeInterval,
|
||||
breakdownField,
|
||||
searchSessionId,
|
||||
totalHitsStatus: totalHitsStatus.toString() as UnifiedHistogramFetchStatus,
|
||||
totalHitsResult,
|
||||
requestAdapter: inspectorAdapters.requests,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
dataView,
|
||||
inspectorAdapters.requests,
|
||||
savedSearchData$.totalHits$,
|
||||
searchSessionId,
|
||||
services,
|
||||
stateContainer.appState,
|
||||
timefilter.getTime,
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Sync Unified Histogram state with Discover state
|
||||
*/
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = createStateSyncObservable(unifiedHistogram?.state$)?.subscribe((state) => {
|
||||
inspectorAdapters.lensRequests = state.lensRequestAdapter;
|
||||
|
||||
const { hideChart, interval, breakdownField } = stateContainer.appState.getState();
|
||||
const oldState = { hideChart, interval, breakdownField };
|
||||
const newState = {
|
||||
hideChart: state.chartHidden,
|
||||
interval: state.timeInterval,
|
||||
breakdownField: state.breakdownField,
|
||||
};
|
||||
|
||||
if (!isEqual(oldState, newState)) {
|
||||
stateContainer.setAppState(newState);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
}, [inspectorAdapters, stateContainer, unifiedHistogram]);
|
||||
|
||||
/**
|
||||
* Update Unified Histgoram request params
|
||||
*/
|
||||
|
||||
const {
|
||||
query,
|
||||
filters,
|
||||
fromDate: from,
|
||||
toDate: to,
|
||||
} = useQuerySubscriber({ data: services.data });
|
||||
|
||||
const timeRange = useMemo(
|
||||
() => (from && to ? { from, to } : timefilter.getTimeDefaults()),
|
||||
[timefilter, from, to]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
unifiedHistogram?.setRequestParams({
|
||||
dataView,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
searchSessionId,
|
||||
requestAdapter: inspectorAdapters.requests,
|
||||
});
|
||||
}, [
|
||||
dataView,
|
||||
filters,
|
||||
inspectorAdapters.requests,
|
||||
query,
|
||||
searchSessionId,
|
||||
timeRange,
|
||||
unifiedHistogram,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Visualize
|
||||
* Override Unified Histgoram total hits with Discover partial results
|
||||
*/
|
||||
|
||||
const timeField = dataView.timeFieldName && dataView.getFieldByName(dataView.timeFieldName);
|
||||
const [canVisualize, setCanVisualize] = useState(false);
|
||||
const firstLoadComplete = useRef(false);
|
||||
|
||||
const { fetchStatus: totalHitsStatus, result: totalHitsResult } = useDataState(
|
||||
savedSearchData$.totalHits$
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeField) {
|
||||
return;
|
||||
}
|
||||
getVisualizeInformation(
|
||||
getUiActions(),
|
||||
timeField,
|
||||
dataView,
|
||||
savedSearch.columns || [],
|
||||
[]
|
||||
).then((info) => {
|
||||
setCanVisualize(Boolean(info));
|
||||
});
|
||||
}, [dataView, savedSearch.columns, timeField]);
|
||||
|
||||
const onEditVisualization = useCallback(
|
||||
(lensAttributes: TypedLensByValueInput['attributes']) => {
|
||||
if (!timeField) {
|
||||
return;
|
||||
}
|
||||
lens.navigateToPrefilledEditor({
|
||||
id: '',
|
||||
timeRange: data.query.timefilter.timefilter.getTime(),
|
||||
attributes: lensAttributes,
|
||||
// We only want to show the partial results on the first load,
|
||||
// or there will be a flickering effect as the loading spinner
|
||||
// is quickly shown and hidden again on fetches
|
||||
if (!firstLoadComplete.current) {
|
||||
unifiedHistogram?.setTotalHits({
|
||||
totalHitsStatus: totalHitsStatus.toString() as UnifiedHistogramFetchStatus,
|
||||
totalHitsResult,
|
||||
});
|
||||
},
|
||||
[data.query.timefilter.timefilter, lens, timeField]
|
||||
);
|
||||
}
|
||||
}, [totalHitsResult, totalHitsStatus, unifiedHistogram]);
|
||||
|
||||
/**
|
||||
* Height
|
||||
* Sync URL query params with Unified Histogram
|
||||
*/
|
||||
|
||||
const [topPanelHeight, setTopPanelHeight] = useState(() => {
|
||||
const storedHeight = storage.get(HISTOGRAM_HEIGHT_KEY);
|
||||
return storedHeight ? Number(storedHeight) : undefined;
|
||||
});
|
||||
const hideChart = useAppStateSelector((state) => state.hideChart);
|
||||
|
||||
const onTopPanelHeightChange = useCallback(
|
||||
(newTopPanelHeight: number | undefined) => {
|
||||
storage.set(HISTOGRAM_HEIGHT_KEY, newTopPanelHeight);
|
||||
setTopPanelHeight(newTopPanelHeight);
|
||||
},
|
||||
[storage]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof hideChart === 'boolean') {
|
||||
unifiedHistogram?.setChartHidden(hideChart);
|
||||
}
|
||||
}, [hideChart, unifiedHistogram]);
|
||||
|
||||
/**
|
||||
* Time interval
|
||||
*/
|
||||
const timeInterval = useAppStateSelector((state) => state.interval);
|
||||
|
||||
const onTimeIntervalChange = useCallback(
|
||||
(newInterval: string) => {
|
||||
stateContainer.setAppState({ interval: newInterval });
|
||||
},
|
||||
[stateContainer]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (timeInterval) {
|
||||
unifiedHistogram?.setTimeInterval(timeInterval);
|
||||
}
|
||||
}, [timeInterval, unifiedHistogram]);
|
||||
|
||||
const breakdownField = useAppStateSelector((state) => state.breakdownField);
|
||||
|
||||
useEffect(() => {
|
||||
unifiedHistogram?.setBreakdownField(breakdownField);
|
||||
}, [breakdownField, unifiedHistogram]);
|
||||
|
||||
/**
|
||||
* Total hits
|
||||
*/
|
||||
|
||||
const [localHitsContext, setLocalHitsContext] = useState<UnifiedHistogramHitsContext>();
|
||||
|
||||
const onTotalHitsChange = useCallback(
|
||||
(status: UnifiedHistogramFetchStatus, result?: number | Error) => {
|
||||
if (result instanceof Error) {
|
||||
// Display the error and set totalHits$ to an error state
|
||||
sendErrorTo(data, savedSearchData$.totalHits$)(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const { fetchStatus, recordRawType } = savedSearchData$.totalHits$.getValue();
|
||||
|
||||
// If we have a partial result already, we don't want to update the total hits back to loading
|
||||
if (fetchStatus === FetchStatus.PARTIAL && status === UnifiedHistogramFetchStatus.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set a local copy of the hits context to pass to unified histogram
|
||||
setLocalHitsContext({ status, total: result });
|
||||
|
||||
// Sync the totalHits$ observable with the unified histogram state
|
||||
savedSearchData$.totalHits$.next({
|
||||
fetchStatus: status.toString() as FetchStatus,
|
||||
result,
|
||||
recordRawType,
|
||||
});
|
||||
|
||||
// Check the hits count to set a partial or no results state
|
||||
if (status === UnifiedHistogramFetchStatus.complete && typeof result === 'number') {
|
||||
checkHitCount(savedSearchData$.main$, result);
|
||||
}
|
||||
},
|
||||
[data, savedSearchData$.main$, savedSearchData$.totalHits$]
|
||||
);
|
||||
|
||||
// We only rely on the totalHits$ observable if we don't have a local hits context yet,
|
||||
// since we only want to show the partial results on the first load, or there will be
|
||||
// a flickering effect as the loading spinner is quickly shown and hidden again on fetches
|
||||
const { fetchStatus: hitsFetchStatus, result: hitsTotal } = useDataState(
|
||||
savedSearchData$.totalHits$
|
||||
);
|
||||
|
||||
const hits = useMemo(
|
||||
() =>
|
||||
isPlainRecord
|
||||
? undefined
|
||||
: localHitsContext ?? {
|
||||
status: hitsFetchStatus.toString() as UnifiedHistogramFetchStatus,
|
||||
total: hitsTotal,
|
||||
},
|
||||
[hitsFetchStatus, hitsTotal, isPlainRecord, localHitsContext]
|
||||
);
|
||||
|
||||
/**
|
||||
* Chart
|
||||
*/
|
||||
|
||||
const onChartHiddenChange = useCallback(
|
||||
(chartHidden: boolean) => {
|
||||
storage.set(CHART_HIDDEN_KEY, chartHidden);
|
||||
stateContainer.setAppState({ hideChart: chartHidden });
|
||||
},
|
||||
[stateContainer, storage]
|
||||
);
|
||||
|
||||
const onChartLoad = useCallback(
|
||||
(event: UnifiedHistogramChartLoadEvent) => {
|
||||
// We need to store the Lens request adapter in order to inspect its requests
|
||||
inspectorAdapters.lensRequests = event.adapters.requests;
|
||||
},
|
||||
[inspectorAdapters]
|
||||
);
|
||||
|
||||
const chart = useMemo(
|
||||
() =>
|
||||
isPlainRecord || !isTimeBased
|
||||
? undefined
|
||||
: {
|
||||
hidden: hideChart,
|
||||
timeInterval: interval,
|
||||
},
|
||||
[hideChart, interval, isPlainRecord, isTimeBased]
|
||||
);
|
||||
|
||||
// Clear the Lens request adapter when the chart is hidden
|
||||
useEffect(() => {
|
||||
if (hideChart || !chart) {
|
||||
inspectorAdapters.lensRequests = undefined;
|
||||
}
|
||||
}, [chart, hideChart, inspectorAdapters]);
|
||||
const subscription = createTotalHitsObservable(unifiedHistogram?.state$)?.subscribe(
|
||||
({ status, result }) => {
|
||||
if (result instanceof Error) {
|
||||
// Display the error and set totalHits$ to an error state
|
||||
sendErrorTo(services.data, savedSearchData$.totalHits$)(result);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breakdown
|
||||
*/
|
||||
const { recordRawType } = savedSearchData$.totalHits$.getValue();
|
||||
|
||||
const onBreakdownFieldChange = useCallback(
|
||||
(newBreakdownField: DataViewField | undefined) => {
|
||||
stateContainer.setAppState({ breakdownField: newBreakdownField?.name });
|
||||
},
|
||||
[stateContainer]
|
||||
);
|
||||
// Sync the totalHits$ observable with the unified histogram state
|
||||
savedSearchData$.totalHits$.next({
|
||||
fetchStatus: status.toString() as FetchStatus,
|
||||
result,
|
||||
recordRawType,
|
||||
});
|
||||
|
||||
const field = useMemo(
|
||||
() => (breakdownField ? dataView.getFieldByName(breakdownField) : undefined),
|
||||
[dataView, breakdownField]
|
||||
);
|
||||
if (status !== UnifiedHistogramFetchStatus.complete || typeof result !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
const breakdown = useMemo(
|
||||
() => (isPlainRecord || !isTimeBased ? undefined : { field }),
|
||||
[field, isPlainRecord, isTimeBased]
|
||||
);
|
||||
// Check the hits count to set a partial or no results state
|
||||
checkHitCount(savedSearchData$.main$, result);
|
||||
|
||||
/**
|
||||
* Search params
|
||||
*/
|
||||
// Indicate the first load has completed so we don't show
|
||||
// partial results on subsequent fetches
|
||||
firstLoadComplete.current = true;
|
||||
}
|
||||
);
|
||||
|
||||
const { query, filters, fromDate: from, toDate: to } = useQuerySubscriber({ data });
|
||||
const timeRange = useMemo(
|
||||
() => (from && to ? { from, to } : data.query.timefilter.timefilter.getTimeDefaults()),
|
||||
[data.query.timefilter.timefilter, from, to]
|
||||
);
|
||||
|
||||
/**
|
||||
* Request
|
||||
*/
|
||||
|
||||
// The searchSessionId will be updated whenever a new search is started
|
||||
const searchSessionId = useObservable(searchSessionManager.searchSessionId$);
|
||||
const request = useMemo(
|
||||
() => ({
|
||||
searchSessionId,
|
||||
adapter: inspectorAdapters.requests,
|
||||
}),
|
||||
[inspectorAdapters.requests, searchSessionId]
|
||||
);
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
}, [savedSearchData$.main$, savedSearchData$.totalHits$, services.data, unifiedHistogram]);
|
||||
|
||||
/**
|
||||
* Data fetching
|
||||
*/
|
||||
|
||||
const input$ = useMemo(() => new Subject<UnifiedHistogramInputMessage>(), []);
|
||||
|
||||
// Initialized when the first search has been requested or
|
||||
// when in SQL mode since search sessions are not supported
|
||||
const isInitialized = Boolean(searchSessionId) || isPlainRecord;
|
||||
const skipRefetch = useRef<boolean>();
|
||||
|
||||
// Skip refetching when showing the chart since Lens will
|
||||
|
@ -292,37 +273,41 @@ export const useDiscoverHistogram = ({
|
|||
// Trigger a unified histogram refetch when savedSearchFetch$ is triggered
|
||||
useEffect(() => {
|
||||
const subscription = savedSearchFetch$.subscribe(() => {
|
||||
if (isInitialized && !skipRefetch.current) {
|
||||
input$.next({ type: 'refetch' });
|
||||
if (!skipRefetch.current) {
|
||||
unifiedHistogram?.refetch();
|
||||
}
|
||||
|
||||
skipRefetch.current = false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [input$, isInitialized, savedSearchFetch$]);
|
||||
}, [savedSearchFetch$, unifiedHistogram]);
|
||||
|
||||
// Don't render the unified histogram layout until initialized
|
||||
return isInitialized
|
||||
? {
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
topPanelHeight,
|
||||
request,
|
||||
hits,
|
||||
chart,
|
||||
breakdown,
|
||||
disableAutoFetching: true,
|
||||
input$,
|
||||
onEditVisualization: canVisualize ? onEditVisualization : undefined,
|
||||
onTopPanelHeightChange,
|
||||
onChartHiddenChange,
|
||||
onTimeIntervalChange,
|
||||
onBreakdownFieldChange,
|
||||
onTotalHitsChange,
|
||||
onChartLoad,
|
||||
}
|
||||
: undefined;
|
||||
return { hideChart, setUnifiedHistogramApi };
|
||||
};
|
||||
|
||||
const createStateSyncObservable = (state$?: Observable<UnifiedHistogramState>) => {
|
||||
return state$?.pipe(
|
||||
map(({ lensRequestAdapter, chartHidden, timeInterval, breakdownField }) => ({
|
||||
lensRequestAdapter,
|
||||
chartHidden,
|
||||
timeInterval,
|
||||
breakdownField,
|
||||
})),
|
||||
distinctUntilChanged((prev, curr) => {
|
||||
const { lensRequestAdapter: prevLensRequestAdapter, ...prevRest } = prev;
|
||||
const { lensRequestAdapter: currLensRequestAdapter, ...currRest } = curr;
|
||||
|
||||
return prevLensRequestAdapter === currLensRequestAdapter && isEqual(prevRest, currRest);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const createTotalHitsObservable = (state$?: Observable<UnifiedHistogramState>) => {
|
||||
return state$?.pipe(
|
||||
map((state) => ({ status: state.totalHitsStatus, result: state.totalHitsResult })),
|
||||
distinctUntilChanged((prev, curr) => prev.status === curr.status && prev.result === curr.result)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import { cloneDeep, isEqual } from 'lodash';
|
||||
import { IUiSettingsClient } from '@kbn/core/public';
|
||||
import { SavedSearch } from '@kbn/saved-search-plugin/public';
|
||||
import { getChartHidden } from '@kbn/unified-histogram-plugin/public';
|
||||
import { AppState } from '../services/discover_app_state_container';
|
||||
import { DiscoverServices } from '../../../build_services';
|
||||
import { getDefaultSort, getSortArray } from '../../../utils/sorting';
|
||||
|
@ -19,8 +20,6 @@ import {
|
|||
SORT_DEFAULT_ORDER_SETTING,
|
||||
} from '../../../../common';
|
||||
|
||||
import { CHART_HIDDEN_KEY } from '../components/layout/use_discover_histogram';
|
||||
|
||||
function getDefaultColumns(savedSearch: SavedSearch, uiSettings: IUiSettingsClient) {
|
||||
if (savedSearch.columns && savedSearch.columns.length > 0) {
|
||||
return [...savedSearch.columns];
|
||||
|
@ -48,7 +47,7 @@ export function getStateDefaults({
|
|||
const query = searchSource.getField('query') || data.query.queryString.getDefaultQuery();
|
||||
const sort = getSortArray(savedSearch.sort ?? [], dataView!);
|
||||
const columns = getDefaultColumns(savedSearch, uiSettings);
|
||||
const chartHidden = storage.get(CHART_HIDDEN_KEY);
|
||||
const chartHidden = getChartHidden(storage, 'discover');
|
||||
|
||||
const defaultState: AppState = {
|
||||
query,
|
||||
|
|
|
@ -1,3 +1,138 @@
|
|||
# unifiedHistogram
|
||||
|
||||
The `unifiedHistogram` plugin provides UI components to create a layout including a resizable histogram and a main display.
|
||||
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.
|
||||
|
||||
## Example
|
||||
|
||||
```tsx
|
||||
// Import the container component and API contract
|
||||
import {
|
||||
UnifiedHistogramContainer,
|
||||
type UnifiedHistogramInitializedApi,
|
||||
} from '@kbn/unified-histogram-plugin/public';
|
||||
|
||||
// Import modules required for your application
|
||||
import {
|
||||
useServices,
|
||||
useResizeRef,
|
||||
useCallbacks,
|
||||
useRequestParams,
|
||||
useManualRefetch,
|
||||
MyLayout,
|
||||
MyButton,
|
||||
} from './my-modules';
|
||||
|
||||
const services = useServices();
|
||||
const resizeRef = useResizeRef();
|
||||
const { onChartHiddenChange, onLensRequestAdapterChange } = useCallbacks();
|
||||
const {
|
||||
dataView,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
searchSessionId,
|
||||
requestAdapter,
|
||||
} = useRequestParams();
|
||||
|
||||
// Use a state variable instead of a ref to preserve reactivity when the API is updated
|
||||
const [unifiedHistogram, setUnifiedHistogram] = useState<UnifiedHistogramInitializedApi>();
|
||||
|
||||
// Create a callback to set unifiedHistogram, and initialize it if needed
|
||||
const setUnifiedHistogramApi = useCallback((api: UnifiedHistogramApi | null) => {
|
||||
// Ignore if the ref is null
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (api.initialized) {
|
||||
// Update our local reference to the API
|
||||
setUnifiedHistogram(api);
|
||||
} else {
|
||||
// Initialize if not yet initialized
|
||||
api.initialize({
|
||||
// Pass the required services to Unified Histogram
|
||||
services,
|
||||
// Optionally provide a local storage key prefix to save parts of the state,
|
||||
// such as the chart hidden state and top panel height, to local storage
|
||||
localStorageKeyPrefix: 'myApp',
|
||||
// By default Unified Histogram will automatically refetch based on certain
|
||||
// state changes, such as chart hidden and request params, but this can be
|
||||
// disabled in favour of manual fetching if preferred. Note that an initial
|
||||
// request is always triggered when first initialized, and when the chart
|
||||
// changes from hidden to visible, Lens will automatically trigger a refetch
|
||||
// regardless of what this property is set to
|
||||
disableAutoFetching: true,
|
||||
// If passing an absolute time range, provide a function to get the relative range
|
||||
getRelativeTimeRange: services.data.query.timefilter.timefilter.getTime,
|
||||
// At minimum the initial state requires a data view, but additional
|
||||
// parameters can be passed to further customize the state
|
||||
initialState: {
|
||||
dataView,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
searchSessionId,
|
||||
requestAdapter,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [...]);
|
||||
|
||||
// Manually refetch if disableAutoFetching is true
|
||||
useManualRefetch(() => {
|
||||
unifiedHistogram?.refetch();
|
||||
});
|
||||
|
||||
// Update the Unified Histogram state when our request params change
|
||||
useEffect(() => {
|
||||
unifiedHistogram?.setRequestParams({
|
||||
dataView,
|
||||
query,
|
||||
filters,
|
||||
timeRange,
|
||||
searchSessionId,
|
||||
requestAdapter,
|
||||
});
|
||||
}, [...]);
|
||||
|
||||
// Listen for state changes if your application requires it
|
||||
useEffect(() => {
|
||||
const subscription = unifiedHistogram?.state$
|
||||
.pipe(map((state) => state.chartHidden), distinctUntilChanged())
|
||||
.subscribe(onChartHiddenChange);
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
}, [...]);
|
||||
|
||||
// Currently Lens does not accept a custom request adapter,
|
||||
// so it will not use the one passed to Unified Histogram.
|
||||
// Instead you can get access to the one it's using by
|
||||
// listening for state changes
|
||||
useEffect(() => {
|
||||
const subscription = unifiedHistogram?.state$
|
||||
.pipe(map((state) => state.lensRequestAdapter), distinctUntilChanged())
|
||||
.subscribe(onLensRequestAdapterChange);
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
}, [...]);
|
||||
|
||||
return (
|
||||
<UnifiedHistogramContainer
|
||||
// Pass the ref callback to receive the API
|
||||
ref={setUnifiedHistogramApi}
|
||||
// Pass a ref to the containing element to
|
||||
// handle top panel resize functionality
|
||||
resizeRef={resizeRef}
|
||||
// Optionally append an element after the
|
||||
// hits counter display
|
||||
appendHitsCounter={<MyButton />}
|
||||
>
|
||||
<MyLayout />
|
||||
</UnifiedHistogramContainer>
|
||||
);
|
||||
```
|
||||
|
|
|
@ -99,6 +99,10 @@ export const buildDataViewMock = ({
|
|||
getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })),
|
||||
isTimeNanosBased: () => false,
|
||||
isPersisted: () => true,
|
||||
getTimeField: () => {
|
||||
return dataViewFields.find((field) => field.name === timeFieldName);
|
||||
},
|
||||
toSpec: () => ({}),
|
||||
} as unknown as DataView;
|
||||
|
||||
dataView.isTimeBased = () => !!timeFieldName;
|
||||
|
|
|
@ -25,6 +25,7 @@ const fields = [
|
|||
filterable: true,
|
||||
aggregatable: true,
|
||||
sortable: true,
|
||||
visualizable: true,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
|
|
|
@ -17,6 +17,9 @@ dataPlugin.query.filterManager.getFilters = jest.fn(() => []);
|
|||
export const unifiedHistogramServicesMock = {
|
||||
data: dataPlugin,
|
||||
fieldFormats: fieldFormatsMock,
|
||||
uiActions: {
|
||||
getTriggerCompatibleActions: jest.fn(() => Promise.resolve([])),
|
||||
},
|
||||
uiSettings: {
|
||||
get: jest.fn(),
|
||||
isDefault: jest.fn(() => true),
|
||||
|
@ -25,5 +28,11 @@ export const unifiedHistogramServicesMock = {
|
|||
useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
|
||||
useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
|
||||
},
|
||||
lens: { EmbeddableComponent: jest.fn(() => null) },
|
||||
lens: { EmbeddableComponent: jest.fn(() => null), navigateToPrefilledEditor: jest.fn() },
|
||||
storage: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
},
|
||||
} as unknown as UnifiedHistogramServices;
|
||||
|
|
|
@ -9,10 +9,10 @@
|
|||
import { EuiComboBox } from '@elastic/eui';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import React from 'react';
|
||||
import { UnifiedHistogramBreakdownContext } from '..';
|
||||
import { UnifiedHistogramBreakdownContext } from '../types';
|
||||
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
|
||||
import { BreakdownFieldSelector } from './breakdown_field_selector';
|
||||
import { fieldSupportsBreakdown } from './field_supports_breakdown';
|
||||
import { fieldSupportsBreakdown } from './utils/field_supports_breakdown';
|
||||
|
||||
describe('BreakdownFieldSelector', () => {
|
||||
it('should pass fields that support breakdown as options to the EuiComboBox', () => {
|
||||
|
|
|
@ -12,7 +12,7 @@ import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { UnifiedHistogramBreakdownContext } from '../types';
|
||||
import { fieldSupportsBreakdown } from './field_supports_breakdown';
|
||||
import { fieldSupportsBreakdown } from './utils/field_supports_breakdown';
|
||||
|
||||
export interface BreakdownFieldSelectorProps {
|
||||
dataView: DataView;
|
||||
|
|
|
@ -20,7 +20,12 @@ import { HitsCounter } from '../hits_counter';
|
|||
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
|
||||
import { dataViewMock } from '../__mocks__/data_view';
|
||||
import { BreakdownFieldSelector } from './breakdown_field_selector';
|
||||
import { Histogram } from './histogram';
|
||||
|
||||
let mockUseEditVisualization: jest.Mock | undefined = jest.fn();
|
||||
|
||||
jest.mock('./hooks/use_edit_visualization', () => ({
|
||||
useEditVisualization: () => mockUseEditVisualization,
|
||||
}));
|
||||
|
||||
async function mountComponent({
|
||||
noChart,
|
||||
|
@ -28,7 +33,6 @@ async function mountComponent({
|
|||
noBreakdown,
|
||||
chartHidden = false,
|
||||
appendHistogram,
|
||||
onEditVisualization = jest.fn(),
|
||||
dataView = dataViewWithTimefieldMock,
|
||||
}: {
|
||||
noChart?: boolean;
|
||||
|
@ -37,7 +41,6 @@ async function mountComponent({
|
|||
chartHidden?: boolean;
|
||||
appendHistogram?: ReactElement;
|
||||
dataView?: DataView;
|
||||
onEditVisualization?: null | (() => void);
|
||||
} = {}) {
|
||||
(searchSourceInstanceMock.fetch$ as jest.Mock).mockImplementation(
|
||||
jest.fn().mockReturnValue(of({ rawResponse: { hits: { total: noHits ? 0 : 2 } } }))
|
||||
|
@ -72,7 +75,6 @@ async function mountComponent({
|
|||
},
|
||||
breakdown: noBreakdown ? undefined : { field: undefined },
|
||||
appendHistogram,
|
||||
onEditVisualization: onEditVisualization || undefined,
|
||||
onResetChartHeight: jest.fn(),
|
||||
onChartHiddenChange: jest.fn(),
|
||||
onTimeIntervalChange: jest.fn(),
|
||||
|
@ -89,6 +91,10 @@ async function mountComponent({
|
|||
}
|
||||
|
||||
describe('Chart', () => {
|
||||
beforeEach(() => {
|
||||
mockUseEditVisualization = jest.fn();
|
||||
});
|
||||
|
||||
test('render when chart is undefined', async () => {
|
||||
const component = await mountComponent({ noChart: true });
|
||||
expect(
|
||||
|
@ -97,7 +103,8 @@ describe('Chart', () => {
|
|||
});
|
||||
|
||||
test('render when chart is defined and onEditVisualization is undefined', async () => {
|
||||
const component = await mountComponent({ onEditVisualization: null });
|
||||
mockUseEditVisualization = undefined;
|
||||
const component = await mountComponent();
|
||||
expect(
|
||||
component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists()
|
||||
).toBeTruthy();
|
||||
|
@ -133,16 +140,15 @@ describe('Chart', () => {
|
|||
});
|
||||
|
||||
test('triggers onEditVisualization on click', async () => {
|
||||
const fn = jest.fn();
|
||||
const component = await mountComponent({ onEditVisualization: fn });
|
||||
expect(mockUseEditVisualization).not.toHaveBeenCalled();
|
||||
const component = await mountComponent();
|
||||
await act(async () => {
|
||||
component
|
||||
.find('[data-test-subj="unifiedHistogramEditVisualization"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
});
|
||||
const lensAttributes = component.find(Histogram).prop('lensAttributes');
|
||||
expect(fn).toHaveBeenCalledWith(lensAttributes);
|
||||
expect(mockUseEditVisualization).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render HitsCounter when hits is defined', async () => {
|
||||
|
|
|
@ -18,12 +18,12 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/public';
|
||||
import type { LensEmbeddableInput, TypedLensByValueInput } from '@kbn/lens-plugin/public';
|
||||
import type { LensEmbeddableInput } from '@kbn/lens-plugin/public';
|
||||
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { Subject } from 'rxjs';
|
||||
import { HitsCounter } from '../hits_counter';
|
||||
import { Histogram } from './histogram';
|
||||
import { useChartPanels } from './use_chart_panels';
|
||||
import { useChartPanels } from './hooks/use_chart_panels';
|
||||
import type {
|
||||
UnifiedHistogramBreakdownContext,
|
||||
UnifiedHistogramChartContext,
|
||||
|
@ -36,12 +36,13 @@ import type {
|
|||
UnifiedHistogramInputMessage,
|
||||
} from '../types';
|
||||
import { BreakdownFieldSelector } from './breakdown_field_selector';
|
||||
import { useTotalHits } from './use_total_hits';
|
||||
import { useRequestParams } from './use_request_params';
|
||||
import { useChartStyles } from './use_chart_styles';
|
||||
import { useChartActions } from './use_chart_actions';
|
||||
import { getLensAttributes } from './get_lens_attributes';
|
||||
import { useRefetch } from './use_refetch';
|
||||
import { useTotalHits } from './hooks/use_total_hits';
|
||||
import { useRequestParams } from './hooks/use_request_params';
|
||||
import { useChartStyles } from './hooks/use_chart_styles';
|
||||
import { useChartActions } from './hooks/use_chart_actions';
|
||||
import { getLensAttributes } from './utils/get_lens_attributes';
|
||||
import { useRefetch } from './hooks/use_refetch';
|
||||
import { useEditVisualization } from './hooks/use_edit_visualization';
|
||||
|
||||
export interface ChartProps {
|
||||
className?: string;
|
||||
|
@ -60,7 +61,7 @@ export interface ChartProps {
|
|||
disableTriggers?: LensEmbeddableInput['disableTriggers'];
|
||||
disabledActions?: LensEmbeddableInput['disabledActions'];
|
||||
input$?: UnifiedHistogramInput$;
|
||||
onEditVisualization?: (lensAttributes: TypedLensByValueInput['attributes']) => void;
|
||||
getRelativeTimeRange?: () => TimeRange;
|
||||
onResetChartHeight?: () => void;
|
||||
onChartHiddenChange?: (chartHidden: boolean) => void;
|
||||
onTimeIntervalChange?: (timeInterval: string) => void;
|
||||
|
@ -90,7 +91,7 @@ export function Chart({
|
|||
disableTriggers,
|
||||
disabledActions,
|
||||
input$: originalInput$,
|
||||
onEditVisualization: originalOnEditVisualization,
|
||||
getRelativeTimeRange: originalGetRelativeTimeRange,
|
||||
onResetChartHeight,
|
||||
onChartHiddenChange,
|
||||
onTimeIntervalChange,
|
||||
|
@ -191,16 +192,18 @@ export function Chart({
|
|||
[breakdown?.field, chart?.timeInterval, chart?.title, dataView, filters, query]
|
||||
);
|
||||
|
||||
const onEditVisualization = useMemo(
|
||||
() =>
|
||||
originalOnEditVisualization
|
||||
? () => {
|
||||
originalOnEditVisualization(lensAttributes);
|
||||
}
|
||||
: undefined,
|
||||
[lensAttributes, originalOnEditVisualization]
|
||||
const getRelativeTimeRange = useMemo(
|
||||
() => originalGetRelativeTimeRange ?? (() => relativeTimeRange),
|
||||
[originalGetRelativeTimeRange, relativeTimeRange]
|
||||
);
|
||||
|
||||
const onEditVisualization = useEditVisualization({
|
||||
services,
|
||||
dataView,
|
||||
getRelativeTimeRange,
|
||||
lensAttributes,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
className={className}
|
||||
|
|
|
@ -12,13 +12,13 @@ import { unifiedHistogramServicesMock } from '../__mocks__/services';
|
|||
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
|
||||
import { createDefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
|
||||
import { UnifiedHistogramFetchStatus, UnifiedHistogramInput$ } from '../types';
|
||||
import { getLensAttributes } from './get_lens_attributes';
|
||||
import { getLensAttributes } from './utils/get_lens_attributes';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import * as buildBucketInterval from './build_bucket_interval';
|
||||
import * as useTimeRange from './use_time_range';
|
||||
import * as buildBucketInterval from './utils/build_bucket_interval';
|
||||
import * as useTimeRange from './hooks/use_time_range';
|
||||
import { RequestStatus } from '@kbn/inspector-plugin/public';
|
||||
import { Subject } from 'rxjs';
|
||||
import { getLensProps } from './use_lens_props';
|
||||
import { getLensProps } from './hooks/use_lens_props';
|
||||
|
||||
const mockBucketInterval = { description: '1 minute', scale: undefined, scaled: false };
|
||||
jest.spyOn(buildBucketInterval, 'buildBucketInterval').mockReturnValue(mockBucketInterval);
|
||||
|
|
|
@ -27,10 +27,10 @@ import {
|
|||
UnifiedHistogramServices,
|
||||
UnifiedHistogramInputMessage,
|
||||
} from '../types';
|
||||
import { buildBucketInterval } from './build_bucket_interval';
|
||||
import { useTimeRange } from './use_time_range';
|
||||
import { useStableCallback } from './use_stable_callback';
|
||||
import { useLensProps } from './use_lens_props';
|
||||
import { buildBucketInterval } from './utils/build_bucket_interval';
|
||||
import { useTimeRange } from './hooks/use_time_range';
|
||||
import { useStableCallback } from './hooks/use_stable_callback';
|
||||
import { useLensProps } from './hooks/use_lens_props';
|
||||
|
||||
export interface HistogramProps {
|
||||
services: UnifiedHistogramServices;
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { act } from 'react-test-renderer';
|
||||
import { UnifiedHistogramChartContext } from '..';
|
||||
import { UnifiedHistogramChartContext } from '../../types';
|
||||
import { useChartActions } from './use_chart_actions';
|
||||
|
||||
describe('useChartActions', () => {
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { UnifiedHistogramChartContext } from '../types';
|
||||
import type { UnifiedHistogramChartContext } from '../../types';
|
||||
|
||||
export const useChartActions = ({
|
||||
chart,
|
|
@ -12,7 +12,7 @@ import type {
|
|||
EuiContextMenuPanelDescriptor,
|
||||
} from '@elastic/eui';
|
||||
import { search } from '@kbn/data-plugin/public';
|
||||
import type { UnifiedHistogramChartContext } from '../types';
|
||||
import type { UnifiedHistogramChartContext } from '../../types';
|
||||
|
||||
export function useChartPanels({
|
||||
chart,
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { act } from 'react-test-renderer';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
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
|
||||
.getTriggerCompatibleActions as jest.Mock;
|
||||
|
||||
const navigateToPrefilledEditor = unifiedHistogramServicesMock.lens
|
||||
.navigateToPrefilledEditor as jest.Mock;
|
||||
|
||||
describe('useEditVisualization', () => {
|
||||
beforeEach(() => {
|
||||
getTriggerCompatibleActions.mockClear();
|
||||
navigateToPrefilledEditor.mockClear();
|
||||
});
|
||||
|
||||
it('should return a function to edit the visualization', async () => {
|
||||
getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }]));
|
||||
const relativeTimeRange = { from: 'now-15m', to: 'now' };
|
||||
const lensAttributes = {
|
||||
visualizationType: 'lnsXY',
|
||||
title: 'test',
|
||||
} as TypedLensByValueInput['attributes'];
|
||||
const hook = renderHook(() =>
|
||||
useEditVisualization({
|
||||
services: unifiedHistogramServicesMock,
|
||||
dataView: dataViewWithTimefieldMock,
|
||||
getRelativeTimeRange: () => relativeTimeRange,
|
||||
lensAttributes,
|
||||
})
|
||||
);
|
||||
await act(() => setTimeout(0));
|
||||
expect(hook.result.current).toBeDefined();
|
||||
hook.result.current!();
|
||||
expect(navigateToPrefilledEditor).toHaveBeenCalledWith({
|
||||
id: '',
|
||||
timeRange: relativeTimeRange,
|
||||
attributes: lensAttributes,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined if the data view has no ID', async () => {
|
||||
getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }]));
|
||||
const hook = renderHook(() =>
|
||||
useEditVisualization({
|
||||
services: unifiedHistogramServicesMock,
|
||||
dataView: { ...dataViewWithTimefieldMock, id: undefined } as DataView,
|
||||
getRelativeTimeRange: () => ({ from: 'now-15m', to: 'now' }),
|
||||
lensAttributes: {} as unknown as TypedLensByValueInput['attributes'],
|
||||
})
|
||||
);
|
||||
await act(() => setTimeout(0));
|
||||
expect(hook.result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if the data view is not time based', async () => {
|
||||
getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }]));
|
||||
const hook = renderHook(() =>
|
||||
useEditVisualization({
|
||||
services: unifiedHistogramServicesMock,
|
||||
dataView: dataViewMock,
|
||||
getRelativeTimeRange: () => ({ from: 'now-15m', to: 'now' }),
|
||||
lensAttributes: {} as unknown as TypedLensByValueInput['attributes'],
|
||||
})
|
||||
);
|
||||
await act(() => setTimeout(0));
|
||||
expect(hook.result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if the time field is not visualizable', async () => {
|
||||
getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }]));
|
||||
const dataView = {
|
||||
...dataViewWithTimefieldMock,
|
||||
getTimeField: () => {
|
||||
return { ...dataViewWithTimefieldMock.getTimeField(), visualizable: false };
|
||||
},
|
||||
} as DataView;
|
||||
const hook = renderHook(() =>
|
||||
useEditVisualization({
|
||||
services: unifiedHistogramServicesMock,
|
||||
dataView,
|
||||
getRelativeTimeRange: () => ({ from: 'now-15m', to: 'now' }),
|
||||
lensAttributes: {} as unknown as TypedLensByValueInput['attributes'],
|
||||
})
|
||||
);
|
||||
await act(() => setTimeout(0));
|
||||
expect(hook.result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if there are no compatible actions', async () => {
|
||||
getTriggerCompatibleActions.mockReturnValue(Promise.resolve([]));
|
||||
const hook = renderHook(() =>
|
||||
useEditVisualization({
|
||||
services: unifiedHistogramServicesMock,
|
||||
dataView: dataViewWithTimefieldMock,
|
||||
getRelativeTimeRange: () => ({ from: 'now-15m', to: 'now' }),
|
||||
lensAttributes: {} as unknown as TypedLensByValueInput['attributes'],
|
||||
})
|
||||
);
|
||||
await act(() => setTimeout(0));
|
||||
expect(hook.result.current).toBeUndefined();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
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 '../..';
|
||||
|
||||
// Avoid taking a dependency on uiActionsPlugin just for this const
|
||||
const visualizeFieldTrigger: typeof VISUALIZE_FIELD_TRIGGER = 'VISUALIZE_FIELD_TRIGGER';
|
||||
|
||||
export const useEditVisualization = ({
|
||||
services,
|
||||
dataView,
|
||||
getRelativeTimeRange,
|
||||
lensAttributes,
|
||||
}: {
|
||||
services: UnifiedHistogramServices;
|
||||
dataView: DataView;
|
||||
getRelativeTimeRange: () => TimeRange;
|
||||
lensAttributes: TypedLensByValueInput['attributes'];
|
||||
}) => {
|
||||
const [canVisualize, setCanVisualize] = useState(false);
|
||||
|
||||
const checkCanVisualize = useCallback(async () => {
|
||||
if (!dataView.id || !dataView.isTimeBased() || !dataView.getTimeField().visualizable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const compatibleActions = await services.uiActions.getTriggerCompatibleActions(
|
||||
visualizeFieldTrigger,
|
||||
{
|
||||
dataViewSpec: dataView.toSpec(false),
|
||||
fieldName: dataView.timeFieldName,
|
||||
}
|
||||
);
|
||||
|
||||
return Boolean(compatibleActions.length);
|
||||
}, [dataView, services.uiActions]);
|
||||
|
||||
const onEditVisualization = useMemo(() => {
|
||||
if (!canVisualize) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return () => {
|
||||
services.lens.navigateToPrefilledEditor({
|
||||
id: '',
|
||||
timeRange: getRelativeTimeRange(),
|
||||
attributes: lensAttributes,
|
||||
});
|
||||
};
|
||||
}, [canVisualize, getRelativeTimeRange, lensAttributes, services.lens]);
|
||||
|
||||
useEffect(() => {
|
||||
checkCanVisualize().then(setCanVisualize);
|
||||
}, [checkCanVisualize]);
|
||||
|
||||
return onEditVisualization;
|
||||
};
|
|
@ -9,9 +9,9 @@
|
|||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { act } from 'react-test-renderer';
|
||||
import { Subject } from 'rxjs';
|
||||
import type { UnifiedHistogramInputMessage } from '../types';
|
||||
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
|
||||
import { getLensAttributes } from './get_lens_attributes';
|
||||
import type { UnifiedHistogramInputMessage } from '../../types';
|
||||
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
|
||||
import { getLensAttributes } from '../utils/get_lens_attributes';
|
||||
import { getLensProps, useLensProps } from './use_lens_props';
|
||||
|
||||
describe('useLensProps', () => {
|
|
@ -12,7 +12,7 @@ import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
|
|||
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Observable } from 'rxjs';
|
||||
import type { UnifiedHistogramInputMessage, UnifiedHistogramRequestContext } from '../types';
|
||||
import type { UnifiedHistogramInputMessage, UnifiedHistogramRequestContext } from '../../types';
|
||||
import { useStableCallback } from './use_stable_callback';
|
||||
|
||||
export const useLensProps = ({
|
|
@ -16,8 +16,8 @@ import {
|
|||
UnifiedHistogramHitsContext,
|
||||
UnifiedHistogramInput$,
|
||||
UnifiedHistogramRequestContext,
|
||||
} from '../types';
|
||||
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
|
||||
} from '../../types';
|
||||
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
describe('useRefetch', () => {
|
|
@ -17,7 +17,7 @@ import {
|
|||
UnifiedHistogramHitsContext,
|
||||
UnifiedHistogramInput$,
|
||||
UnifiedHistogramRequestContext,
|
||||
} from '../types';
|
||||
} from '../../types';
|
||||
|
||||
export const useRefetch = ({
|
||||
dataView,
|
||||
|
@ -48,7 +48,7 @@ export const useRefetch = ({
|
|||
}) => {
|
||||
const refetchDeps = useRef<ReturnType<typeof getRefetchDeps>>();
|
||||
|
||||
// When the unified histogram props change, we must compare the current subset
|
||||
// When the Unified Histogram props change, we must compare the current subset
|
||||
// that should trigger a histogram refetch against the previous subset. If they
|
||||
// are different, we must refetch the histogram to ensure it's up to date.
|
||||
useEffect(() => {
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { unifiedHistogramServicesMock } from '../__mocks__/services';
|
||||
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
|
||||
|
||||
const getUseRequestParams = async () => {
|
||||
jest.doMock('@kbn/data-plugin/common', () => {
|
|
@ -9,7 +9,7 @@
|
|||
import { getAbsoluteTimeRange } from '@kbn/data-plugin/common';
|
||||
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { UnifiedHistogramServices } from '../types';
|
||||
import type { UnifiedHistogramServices } from '../../types';
|
||||
import { useStableCallback } from './use_stable_callback';
|
||||
|
||||
export const useRequestParams = ({
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Accepts a callback and returns a function with a stable identity
|
||||
|
@ -19,5 +19,5 @@ export const useStableCallback = <T extends (...args: any[]) => any>(fn: T | und
|
|||
ref.current = fn;
|
||||
}, [fn]);
|
||||
|
||||
return useCallback((...args: Parameters<T>) => ref.current?.(...args), []);
|
||||
return useRef((...args: Parameters<T>) => ref.current?.(...args)).current;
|
||||
};
|
|
@ -9,7 +9,7 @@
|
|||
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
|
||||
import { TimeRange } from '@kbn/data-plugin/common';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { UnifiedHistogramBucketInterval } from '../types';
|
||||
import { UnifiedHistogramBucketInterval } from '../../types';
|
||||
import { useTimeRange } from './use_time_range';
|
||||
|
||||
jest.mock('@kbn/datemath', () => ({
|
|
@ -13,7 +13,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,
|
|
@ -7,8 +7,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 { renderHook } from '@testing-library/react-hooks';
|
|
@ -19,7 +19,7 @@ import {
|
|||
UnifiedHistogramInputMessage,
|
||||
UnifiedHistogramRequestContext,
|
||||
UnifiedHistogramServices,
|
||||
} from '../types';
|
||||
} from '../../types';
|
||||
import { useStableCallback } from './use_stable_callback';
|
||||
|
||||
export const useTotalHits = ({
|
|
@ -7,7 +7,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';
|
||||
|
|
@ -10,12 +10,12 @@ 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';
|
||||
|
||||
/**
|
||||
* Convert the response from the chart request into a format that can be used
|
||||
* by the unified histogram chart. The returned object should be used to update
|
||||
* by the Unified Histogram chart. The returned object should be used to update
|
||||
* time range interval of histogram.
|
||||
*/
|
||||
export const buildBucketInterval = ({
|
|
@ -6,7 +6,7 @@
|
|||
* 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';
|
||||
|
|
@ -11,7 +11,7 @@ import type { DataView } from '@kbn/data-views-plugin/common';
|
|||
import type { TimeRange } from '@kbn/es-query';
|
||||
|
||||
/**
|
||||
* Helper function to get the agg configs required for the unified histogram chart request
|
||||
* Helper function to get the agg configs required for the Unified Histogram chart request
|
||||
*/
|
||||
export function getChartAggConfigs({
|
||||
dataView,
|
|
@ -9,7 +9,7 @@
|
|||
import { getLensAttributes } from './get_lens_attributes';
|
||||
import { AggregateQuery, Filter, FilterStateStore, Query } from '@kbn/es-query';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
|
||||
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
|
||||
|
||||
describe('getLensAttributes', () => {
|
||||
const dataView: DataView = dataViewWithTimefieldMock;
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { RequestAdapter } from '@kbn/inspector-plugin/common';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { UnifiedHistogramFetchStatus } from '../types';
|
||||
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
|
||||
import { unifiedHistogramServicesMock } from '../__mocks__/services';
|
||||
import { UnifiedHistogramApi, UnifiedHistogramContainer } from './container';
|
||||
import type { UnifiedHistogramState } from './services/state_service';
|
||||
|
||||
describe('UnifiedHistogramContainer', () => {
|
||||
const initialState: UnifiedHistogramState = {
|
||||
breakdownField: 'bytes',
|
||||
chartHidden: false,
|
||||
dataView: dataViewWithTimefieldMock,
|
||||
filters: [],
|
||||
lensRequestAdapter: new RequestAdapter(),
|
||||
query: { language: 'kuery', query: '' },
|
||||
requestAdapter: new RequestAdapter(),
|
||||
searchSessionId: '123',
|
||||
timeInterval: 'auto',
|
||||
timeRange: { from: 'now-15m', to: 'now' },
|
||||
topPanelHeight: 100,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
|
||||
totalHitsResult: undefined,
|
||||
};
|
||||
|
||||
it('should set ref', () => {
|
||||
let api: UnifiedHistogramApi | undefined;
|
||||
const setApi = (ref: UnifiedHistogramApi) => {
|
||||
api = ref;
|
||||
};
|
||||
mountWithIntl(<UnifiedHistogramContainer ref={setApi} resizeRef={{ current: null }} />);
|
||||
expect(api).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return null if not initialized', async () => {
|
||||
const component = mountWithIntl(<UnifiedHistogramContainer resizeRef={{ current: null }} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(component.update().isEmptyRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not return null if initialized', async () => {
|
||||
const setApi = (api: UnifiedHistogramApi | null) => {
|
||||
if (!api || api.initialized) {
|
||||
return;
|
||||
}
|
||||
api?.initialize({
|
||||
services: unifiedHistogramServicesMock,
|
||||
initialState,
|
||||
});
|
||||
};
|
||||
const component = mountWithIntl(
|
||||
<UnifiedHistogramContainer ref={setApi} resizeRef={{ current: null }} />
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(component.update().isEmptyRender()).toBe(false);
|
||||
});
|
||||
|
||||
it('should update initialized property when initialized', async () => {
|
||||
let api: UnifiedHistogramApi | undefined;
|
||||
const setApi = (ref: UnifiedHistogramApi) => {
|
||||
api = ref;
|
||||
};
|
||||
mountWithIntl(<UnifiedHistogramContainer ref={setApi} resizeRef={{ current: null }} />);
|
||||
expect(api?.initialized).toBe(false);
|
||||
act(() => {
|
||||
if (!api?.initialized) {
|
||||
api?.initialize({
|
||||
services: unifiedHistogramServicesMock,
|
||||
initialState,
|
||||
});
|
||||
}
|
||||
});
|
||||
expect(api?.initialized).toBe(true);
|
||||
});
|
||||
});
|
172
src/plugins/unified_histogram/public/container/container.tsx
Normal file
172
src/plugins/unified_histogram/public/container/container.tsx
Normal file
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
|
||||
import { Subject } from 'rxjs';
|
||||
import { pick } from 'lodash';
|
||||
import { UnifiedHistogramLayout, UnifiedHistogramLayoutProps } from '../layout';
|
||||
import type { UnifiedHistogramInputMessage } from '../types';
|
||||
import {
|
||||
createStateService,
|
||||
UnifiedHistogramStateOptions,
|
||||
UnifiedHistogramStateService,
|
||||
} from './services/state_service';
|
||||
import { useStateProps } from './hooks/use_state_props';
|
||||
import { useStateSelector } from './utils/use_state_selector';
|
||||
import {
|
||||
dataViewSelector,
|
||||
filtersSelector,
|
||||
querySelector,
|
||||
timeRangeSelector,
|
||||
topPanelHeightSelector,
|
||||
} from './utils/state_selectors';
|
||||
|
||||
type LayoutProps = Pick<
|
||||
UnifiedHistogramLayoutProps,
|
||||
| 'services'
|
||||
| 'disableAutoFetching'
|
||||
| 'disableTriggers'
|
||||
| 'disabledActions'
|
||||
| 'getRelativeTimeRange'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The props exposed by the container
|
||||
*/
|
||||
export type UnifiedHistogramContainerProps = Pick<
|
||||
UnifiedHistogramLayoutProps,
|
||||
'className' | 'resizeRef' | 'appendHitsCounter' | 'children'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The options used to initialize the container
|
||||
*/
|
||||
export type UnifiedHistogramInitializeOptions = UnifiedHistogramStateOptions &
|
||||
Omit<LayoutProps, 'services'>;
|
||||
|
||||
/**
|
||||
* The uninitialized API exposed by the container
|
||||
*/
|
||||
export interface UnifiedHistogramUninitializedApi {
|
||||
/**
|
||||
* Whether the container has been initialized
|
||||
*/
|
||||
initialized: false;
|
||||
/**
|
||||
* Initialize the container
|
||||
*/
|
||||
initialize: (options: UnifiedHistogramInitializeOptions) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The initialized API exposed by the container
|
||||
*/
|
||||
export type UnifiedHistogramInitializedApi = {
|
||||
/**
|
||||
* Whether the container has been initialized
|
||||
*/
|
||||
initialized: true;
|
||||
/**
|
||||
* Manually trigger a refetch of the data
|
||||
*/
|
||||
refetch: () => void;
|
||||
} & Pick<
|
||||
UnifiedHistogramStateService,
|
||||
| 'state$'
|
||||
| 'setChartHidden'
|
||||
| 'setTopPanelHeight'
|
||||
| 'setBreakdownField'
|
||||
| 'setTimeInterval'
|
||||
| 'setRequestParams'
|
||||
| 'setTotalHits'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The API exposed by the container
|
||||
*/
|
||||
export type UnifiedHistogramApi = UnifiedHistogramUninitializedApi | UnifiedHistogramInitializedApi;
|
||||
|
||||
export const UnifiedHistogramContainer = forwardRef<
|
||||
UnifiedHistogramApi,
|
||||
UnifiedHistogramContainerProps
|
||||
>((containerProps, ref) => {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [layoutProps, setLayoutProps] = useState<LayoutProps>();
|
||||
const [stateService, setStateService] = useState<UnifiedHistogramStateService>();
|
||||
const [input$] = useState(() => new Subject<UnifiedHistogramInputMessage>());
|
||||
const api = useMemo<UnifiedHistogramApi>(
|
||||
() => ({
|
||||
initialized,
|
||||
initialize: (options: UnifiedHistogramInitializeOptions) => {
|
||||
const {
|
||||
services,
|
||||
disableAutoFetching,
|
||||
disableTriggers,
|
||||
disabledActions,
|
||||
getRelativeTimeRange,
|
||||
} = options;
|
||||
|
||||
setLayoutProps({
|
||||
services,
|
||||
disableAutoFetching,
|
||||
disableTriggers,
|
||||
disabledActions,
|
||||
getRelativeTimeRange,
|
||||
});
|
||||
setStateService(createStateService(options));
|
||||
setInitialized(true);
|
||||
},
|
||||
refetch: () => {
|
||||
input$.next({ type: 'refetch' });
|
||||
},
|
||||
...pick(
|
||||
stateService!,
|
||||
'state$',
|
||||
'setChartHidden',
|
||||
'setTopPanelHeight',
|
||||
'setBreakdownField',
|
||||
'setTimeInterval',
|
||||
'setRequestParams',
|
||||
'setTotalHits'
|
||||
),
|
||||
}),
|
||||
[initialized, input$, stateService]
|
||||
);
|
||||
|
||||
// Expose the API to the parent component
|
||||
useImperativeHandle(ref, () => api, [api]);
|
||||
|
||||
const stateProps = useStateProps(stateService);
|
||||
const dataView = useStateSelector(stateService?.state$, dataViewSelector);
|
||||
const query = useStateSelector(stateService?.state$, querySelector);
|
||||
const filters = useStateSelector(stateService?.state$, filtersSelector);
|
||||
const timeRange = useStateSelector(stateService?.state$, timeRangeSelector);
|
||||
const topPanelHeight = useStateSelector(stateService?.state$, topPanelHeightSelector);
|
||||
|
||||
// Don't render anything until the container is initialized
|
||||
if (!layoutProps || !dataView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedHistogramLayout
|
||||
{...containerProps}
|
||||
{...layoutProps}
|
||||
{...stateProps}
|
||||
dataView={dataView}
|
||||
query={query}
|
||||
filters={filters}
|
||||
timeRange={timeRange}
|
||||
topPanelHeight={topPanelHeight}
|
||||
input$={input$}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default UnifiedHistogramContainer;
|
|
@ -0,0 +1,265 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/common';
|
||||
import { RequestAdapter } from '@kbn/inspector-plugin/common';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { act } from 'react-test-renderer';
|
||||
import { UnifiedHistogramFetchStatus } from '../../types';
|
||||
import { dataViewMock } from '../../__mocks__/data_view';
|
||||
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
|
||||
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
|
||||
import {
|
||||
createStateService,
|
||||
UnifiedHistogramState,
|
||||
UnifiedHistogramStateOptions,
|
||||
} from '../services/state_service';
|
||||
import { useStateProps } from './use_state_props';
|
||||
|
||||
describe('useStateProps', () => {
|
||||
const initialState: UnifiedHistogramState = {
|
||||
breakdownField: 'bytes',
|
||||
chartHidden: false,
|
||||
dataView: dataViewWithTimefieldMock,
|
||||
filters: [],
|
||||
lensRequestAdapter: new RequestAdapter(),
|
||||
query: { language: 'kuery', query: '' },
|
||||
requestAdapter: new RequestAdapter(),
|
||||
searchSessionId: '123',
|
||||
timeInterval: 'auto',
|
||||
timeRange: { from: 'now-15m', to: 'now' },
|
||||
topPanelHeight: 100,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
|
||||
totalHitsResult: undefined,
|
||||
};
|
||||
|
||||
const getStateService = (options: Omit<UnifiedHistogramStateOptions, 'services'>) => {
|
||||
const stateService = createStateService({
|
||||
...options,
|
||||
services: unifiedHistogramServicesMock,
|
||||
});
|
||||
jest.spyOn(stateService, 'setChartHidden');
|
||||
jest.spyOn(stateService, 'setTopPanelHeight');
|
||||
jest.spyOn(stateService, 'setBreakdownField');
|
||||
jest.spyOn(stateService, 'setTimeInterval');
|
||||
jest.spyOn(stateService, 'setRequestParams');
|
||||
jest.spyOn(stateService, 'setLensRequestAdapter');
|
||||
jest.spyOn(stateService, 'setTotalHits');
|
||||
return stateService;
|
||||
};
|
||||
|
||||
it('should return the correct props', () => {
|
||||
const stateService = getStateService({ initialState });
|
||||
const { result } = renderHook(() => useStateProps(stateService));
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"breakdown": Object {
|
||||
"field": Object {
|
||||
"aggregatable": true,
|
||||
"displayName": "bytes",
|
||||
"filterable": true,
|
||||
"name": "bytes",
|
||||
"scripted": false,
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"chart": Object {
|
||||
"hidden": false,
|
||||
"timeInterval": "auto",
|
||||
},
|
||||
"hits": Object {
|
||||
"status": "uninitialized",
|
||||
"total": undefined,
|
||||
},
|
||||
"onBreakdownFieldChange": [Function],
|
||||
"onChartHiddenChange": [Function],
|
||||
"onChartLoad": [Function],
|
||||
"onTimeIntervalChange": [Function],
|
||||
"onTopPanelHeightChange": [Function],
|
||||
"onTotalHitsChange": [Function],
|
||||
"request": Object {
|
||||
"adapter": RequestAdapter {
|
||||
"_events": Object {},
|
||||
"_eventsCount": 0,
|
||||
"_maxListeners": undefined,
|
||||
"requests": Map {},
|
||||
Symbol(kCapture): false,
|
||||
},
|
||||
"searchSessionId": "123",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return the correct props when an SQL query is used', () => {
|
||||
const stateService = getStateService({
|
||||
initialState: { ...initialState, query: { sql: 'SELECT * FROM index' } },
|
||||
});
|
||||
const { result } = renderHook(() => useStateProps(stateService));
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"breakdown": undefined,
|
||||
"chart": undefined,
|
||||
"hits": undefined,
|
||||
"onBreakdownFieldChange": [Function],
|
||||
"onChartHiddenChange": [Function],
|
||||
"onChartLoad": [Function],
|
||||
"onTimeIntervalChange": [Function],
|
||||
"onTopPanelHeightChange": [Function],
|
||||
"onTotalHitsChange": [Function],
|
||||
"request": Object {
|
||||
"adapter": RequestAdapter {
|
||||
"_events": Object {},
|
||||
"_eventsCount": 0,
|
||||
"_maxListeners": undefined,
|
||||
"requests": Map {},
|
||||
Symbol(kCapture): false,
|
||||
},
|
||||
"searchSessionId": "123",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return the correct props when a rollup data view is used', () => {
|
||||
const stateService = getStateService({
|
||||
initialState: {
|
||||
...initialState,
|
||||
dataView: {
|
||||
...dataViewWithTimefieldMock,
|
||||
type: DataViewType.ROLLUP,
|
||||
} as DataView,
|
||||
},
|
||||
});
|
||||
const { result } = renderHook(() => useStateProps(stateService));
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"breakdown": undefined,
|
||||
"chart": undefined,
|
||||
"hits": Object {
|
||||
"status": "uninitialized",
|
||||
"total": undefined,
|
||||
},
|
||||
"onBreakdownFieldChange": [Function],
|
||||
"onChartHiddenChange": [Function],
|
||||
"onChartLoad": [Function],
|
||||
"onTimeIntervalChange": [Function],
|
||||
"onTopPanelHeightChange": [Function],
|
||||
"onTotalHitsChange": [Function],
|
||||
"request": Object {
|
||||
"adapter": RequestAdapter {
|
||||
"_events": Object {},
|
||||
"_eventsCount": 0,
|
||||
"_maxListeners": undefined,
|
||||
"requests": Map {},
|
||||
Symbol(kCapture): false,
|
||||
},
|
||||
"searchSessionId": "123",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return the correct props when a non time based data view is used', () => {
|
||||
const stateService = getStateService({
|
||||
initialState: { ...initialState, dataView: dataViewMock },
|
||||
});
|
||||
const { result } = renderHook(() => useStateProps(stateService));
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"breakdown": undefined,
|
||||
"chart": undefined,
|
||||
"hits": Object {
|
||||
"status": "uninitialized",
|
||||
"total": undefined,
|
||||
},
|
||||
"onBreakdownFieldChange": [Function],
|
||||
"onChartHiddenChange": [Function],
|
||||
"onChartLoad": [Function],
|
||||
"onTimeIntervalChange": [Function],
|
||||
"onTopPanelHeightChange": [Function],
|
||||
"onTotalHitsChange": [Function],
|
||||
"request": Object {
|
||||
"adapter": RequestAdapter {
|
||||
"_events": Object {},
|
||||
"_eventsCount": 0,
|
||||
"_maxListeners": undefined,
|
||||
"requests": Map {},
|
||||
Symbol(kCapture): false,
|
||||
},
|
||||
"searchSessionId": "123",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should execute callbacks correctly', () => {
|
||||
const stateService = getStateService({ initialState });
|
||||
const { result } = renderHook(() => useStateProps(stateService));
|
||||
const {
|
||||
onTopPanelHeightChange,
|
||||
onTimeIntervalChange,
|
||||
onTotalHitsChange,
|
||||
onChartHiddenChange,
|
||||
onChartLoad,
|
||||
onBreakdownFieldChange,
|
||||
} = result.current;
|
||||
act(() => {
|
||||
onTopPanelHeightChange(200);
|
||||
});
|
||||
expect(stateService.setTopPanelHeight).toHaveBeenLastCalledWith(200);
|
||||
act(() => {
|
||||
onTimeIntervalChange('1d');
|
||||
});
|
||||
expect(stateService.setTimeInterval).toHaveBeenLastCalledWith('1d');
|
||||
act(() => {
|
||||
onTotalHitsChange(UnifiedHistogramFetchStatus.complete, 100);
|
||||
});
|
||||
expect(stateService.setTotalHits).toHaveBeenLastCalledWith({
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.complete,
|
||||
totalHitsResult: 100,
|
||||
});
|
||||
act(() => {
|
||||
onChartHiddenChange(true);
|
||||
});
|
||||
expect(stateService.setChartHidden).toHaveBeenLastCalledWith(true);
|
||||
const requests = new RequestAdapter();
|
||||
act(() => {
|
||||
onChartLoad({ adapters: { requests } });
|
||||
});
|
||||
expect(stateService.setLensRequestAdapter).toHaveBeenLastCalledWith(requests);
|
||||
act(() => {
|
||||
onBreakdownFieldChange({ name: 'field' } as DataViewField);
|
||||
});
|
||||
expect(stateService.setBreakdownField).toHaveBeenLastCalledWith('field');
|
||||
});
|
||||
|
||||
it('should clear lensRequestAdapter when chart is hidden', () => {
|
||||
const stateService = getStateService({ initialState });
|
||||
const hook = renderHook(() => useStateProps(stateService));
|
||||
(stateService.setLensRequestAdapter as jest.Mock).mockClear();
|
||||
expect(stateService.setLensRequestAdapter).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
stateService.setChartHidden(true);
|
||||
});
|
||||
hook.rerender();
|
||||
expect(stateService.setLensRequestAdapter).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should clear lensRequestAdapter when chart is undefined', () => {
|
||||
const stateService = getStateService({ initialState });
|
||||
const hook = renderHook(() => useStateProps(stateService));
|
||||
(stateService.setLensRequestAdapter as jest.Mock).mockClear();
|
||||
expect(stateService.setLensRequestAdapter).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
stateService.setRequestParams({ dataView: dataViewMock });
|
||||
});
|
||||
hook.rerender();
|
||||
expect(stateService.setLensRequestAdapter).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DataViewField, DataViewType } from '@kbn/data-views-plugin/common';
|
||||
import { getAggregateQueryMode, isOfAggregateQueryType } from '@kbn/es-query';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { UnifiedHistogramChartLoadEvent, UnifiedHistogramFetchStatus } from '../../types';
|
||||
import type { UnifiedHistogramStateService } from '../services/state_service';
|
||||
import {
|
||||
breakdownFieldSelector,
|
||||
chartHiddenSelector,
|
||||
dataViewSelector,
|
||||
querySelector,
|
||||
requestAdapterSelector,
|
||||
searchSessionIdSelector,
|
||||
timeIntervalSelector,
|
||||
totalHitsResultSelector,
|
||||
totalHitsStatusSelector,
|
||||
} from '../utils/state_selectors';
|
||||
import { useStateSelector } from '../utils/use_state_selector';
|
||||
|
||||
export const useStateProps = (stateService: UnifiedHistogramStateService | undefined) => {
|
||||
const breakdownField = useStateSelector(stateService?.state$, breakdownFieldSelector);
|
||||
const chartHidden = useStateSelector(stateService?.state$, chartHiddenSelector);
|
||||
const dataView = useStateSelector(stateService?.state$, dataViewSelector);
|
||||
const query = useStateSelector(stateService?.state$, querySelector);
|
||||
const requestAdapter = useStateSelector(stateService?.state$, requestAdapterSelector);
|
||||
const searchSessionId = useStateSelector(stateService?.state$, searchSessionIdSelector);
|
||||
const timeInterval = useStateSelector(stateService?.state$, timeIntervalSelector);
|
||||
const totalHitsResult = useStateSelector(stateService?.state$, totalHitsResultSelector);
|
||||
const totalHitsStatus = useStateSelector(stateService?.state$, totalHitsStatusSelector);
|
||||
|
||||
/**
|
||||
* Contexts
|
||||
*/
|
||||
|
||||
const isPlainRecord = useMemo(() => {
|
||||
return query && isOfAggregateQueryType(query) && getAggregateQueryMode(query) === 'sql';
|
||||
}, [query]);
|
||||
|
||||
const isTimeBased = useMemo(() => {
|
||||
return dataView && dataView.type !== DataViewType.ROLLUP && dataView.isTimeBased();
|
||||
}, [dataView]);
|
||||
|
||||
const hits = useMemo(() => {
|
||||
if (isPlainRecord || totalHitsResult instanceof Error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
status: totalHitsStatus,
|
||||
total: totalHitsResult,
|
||||
};
|
||||
}, [isPlainRecord, totalHitsResult, totalHitsStatus]);
|
||||
|
||||
const chart = useMemo(() => {
|
||||
if (isPlainRecord || !isTimeBased) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
hidden: chartHidden,
|
||||
timeInterval,
|
||||
};
|
||||
}, [chartHidden, isPlainRecord, isTimeBased, timeInterval]);
|
||||
|
||||
const breakdown = useMemo(() => {
|
||||
if (isPlainRecord || !isTimeBased) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
field: breakdownField ? dataView?.getFieldByName(breakdownField) : undefined,
|
||||
};
|
||||
}, [breakdownField, dataView, isPlainRecord, isTimeBased]);
|
||||
|
||||
const request = useMemo(() => {
|
||||
return {
|
||||
searchSessionId,
|
||||
adapter: requestAdapter,
|
||||
};
|
||||
}, [requestAdapter, searchSessionId]);
|
||||
|
||||
/**
|
||||
* Callbacks
|
||||
*/
|
||||
|
||||
const onTopPanelHeightChange = useCallback(
|
||||
(topPanelHeight: number | undefined) => {
|
||||
stateService?.setTopPanelHeight(topPanelHeight);
|
||||
},
|
||||
[stateService]
|
||||
);
|
||||
|
||||
const onTimeIntervalChange = useCallback(
|
||||
(newTimeInterval: string) => {
|
||||
stateService?.setTimeInterval(newTimeInterval);
|
||||
},
|
||||
[stateService]
|
||||
);
|
||||
|
||||
const onTotalHitsChange = useCallback(
|
||||
(newTotalHitsStatus: UnifiedHistogramFetchStatus, newTotalHitsResult?: number | Error) => {
|
||||
stateService?.setTotalHits({
|
||||
totalHitsStatus: newTotalHitsStatus,
|
||||
totalHitsResult: newTotalHitsResult,
|
||||
});
|
||||
},
|
||||
[stateService]
|
||||
);
|
||||
|
||||
const onChartHiddenChange = useCallback(
|
||||
(newChartHidden: boolean) => {
|
||||
stateService?.setChartHidden(newChartHidden);
|
||||
},
|
||||
[stateService]
|
||||
);
|
||||
|
||||
const onChartLoad = useCallback(
|
||||
(event: UnifiedHistogramChartLoadEvent) => {
|
||||
// We need to store the Lens request adapter in order to inspect its requests
|
||||
stateService?.setLensRequestAdapter(event.adapters.requests);
|
||||
},
|
||||
[stateService]
|
||||
);
|
||||
|
||||
const onBreakdownFieldChange = useCallback(
|
||||
(newBreakdownField: DataViewField | undefined) => {
|
||||
stateService?.setBreakdownField(newBreakdownField?.name);
|
||||
},
|
||||
[stateService]
|
||||
);
|
||||
|
||||
/**
|
||||
* Effects
|
||||
*/
|
||||
|
||||
// Clear the Lens request adapter when the chart is hidden
|
||||
useEffect(() => {
|
||||
if (chartHidden || !chart) {
|
||||
stateService?.setLensRequestAdapter(undefined);
|
||||
}
|
||||
}, [chart, chartHidden, stateService]);
|
||||
|
||||
return {
|
||||
hits,
|
||||
chart,
|
||||
breakdown,
|
||||
request,
|
||||
onTopPanelHeightChange,
|
||||
onTimeIntervalChange,
|
||||
onTotalHitsChange,
|
||||
onChartHiddenChange,
|
||||
onChartLoad,
|
||||
onBreakdownFieldChange,
|
||||
};
|
||||
};
|
|
@ -10,9 +10,24 @@ import { EuiDelayRender, EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui';
|
|||
import { withSuspense } from '@kbn/shared-ux-utility';
|
||||
import React, { lazy } from 'react';
|
||||
|
||||
export type { UnifiedHistogramLayoutProps } from './layout';
|
||||
export type {
|
||||
UnifiedHistogramUninitializedApi,
|
||||
UnifiedHistogramInitializedApi,
|
||||
UnifiedHistogramApi,
|
||||
UnifiedHistogramContainerProps,
|
||||
UnifiedHistogramInitializeOptions,
|
||||
} from './container';
|
||||
export type { UnifiedHistogramState, UnifiedHistogramStateOptions } from './services/state_service';
|
||||
export {
|
||||
getChartHidden,
|
||||
getTopPanelHeight,
|
||||
getBreakdownField,
|
||||
setChartHidden,
|
||||
setTopPanelHeight,
|
||||
setBreakdownField,
|
||||
} from './utils/local_storage_utils';
|
||||
|
||||
const LazyUnifiedHistogramLayout = lazy(() => import('./layout'));
|
||||
const LazyUnifiedHistogramContainer = lazy(() => import('./container'));
|
||||
|
||||
/**
|
||||
* A resizable layout component with two panels that renders a histogram with a hits
|
||||
|
@ -20,8 +35,8 @@ const LazyUnifiedHistogramLayout = lazy(() => import('./layout'));
|
|||
* If all context props are left undefined, the layout will render in a single panel
|
||||
* mode including only the main display.
|
||||
*/
|
||||
export const UnifiedHistogramLayout = withSuspense(
|
||||
LazyUnifiedHistogramLayout,
|
||||
export const UnifiedHistogramContainer = withSuspense(
|
||||
LazyUnifiedHistogramContainer,
|
||||
<EuiDelayRender delay={300}>
|
||||
<EuiFlexGroup
|
||||
className="eui-fullHeight"
|
|
@ -0,0 +1,259 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { RequestAdapter } from '@kbn/inspector-plugin/common';
|
||||
import { UnifiedHistogramFetchStatus } from '../..';
|
||||
import { dataViewMock } from '../../__mocks__/data_view';
|
||||
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
|
||||
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
|
||||
import {
|
||||
getChartHidden,
|
||||
getTopPanelHeight,
|
||||
getBreakdownField,
|
||||
setChartHidden,
|
||||
setTopPanelHeight,
|
||||
setBreakdownField,
|
||||
} from '../utils/local_storage_utils';
|
||||
import { createStateService, UnifiedHistogramState } from './state_service';
|
||||
|
||||
jest.mock('../utils/local_storage_utils', () => {
|
||||
const originalModule = jest.requireActual('../utils/local_storage_utils');
|
||||
return {
|
||||
...originalModule,
|
||||
getChartHidden: jest.fn(originalModule.getChartHidden),
|
||||
getTopPanelHeight: jest.fn(originalModule.getTopPanelHeight),
|
||||
getBreakdownField: jest.fn(originalModule.getBreakdownField),
|
||||
setChartHidden: jest.fn(originalModule.setChartHidden),
|
||||
setTopPanelHeight: jest.fn(originalModule.setTopPanelHeight),
|
||||
setBreakdownField: jest.fn(originalModule.setBreakdownField),
|
||||
};
|
||||
});
|
||||
|
||||
describe('UnifiedHistogramStateService', () => {
|
||||
beforeEach(() => {
|
||||
(getChartHidden as jest.Mock).mockClear();
|
||||
(getTopPanelHeight as jest.Mock).mockClear();
|
||||
(getBreakdownField as jest.Mock).mockClear();
|
||||
(setChartHidden as jest.Mock).mockClear();
|
||||
(setTopPanelHeight as jest.Mock).mockClear();
|
||||
(setBreakdownField as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
const initialState: UnifiedHistogramState = {
|
||||
breakdownField: 'bytes',
|
||||
chartHidden: false,
|
||||
dataView: dataViewWithTimefieldMock,
|
||||
filters: [],
|
||||
lensRequestAdapter: new RequestAdapter(),
|
||||
query: { language: 'kuery', query: '' },
|
||||
requestAdapter: new RequestAdapter(),
|
||||
searchSessionId: '123',
|
||||
timeInterval: 'auto',
|
||||
timeRange: { from: 'now-15m', to: 'now' },
|
||||
topPanelHeight: 100,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
|
||||
totalHitsResult: undefined,
|
||||
};
|
||||
|
||||
it('should initialize state with default values', () => {
|
||||
const stateService = createStateService({
|
||||
services: unifiedHistogramServicesMock,
|
||||
initialState: {
|
||||
dataView: dataViewWithTimefieldMock,
|
||||
},
|
||||
});
|
||||
let state: UnifiedHistogramState | undefined;
|
||||
stateService.state$.subscribe((s) => (state = s));
|
||||
expect(state).toEqual({
|
||||
breakdownField: undefined,
|
||||
chartHidden: false,
|
||||
dataView: dataViewWithTimefieldMock,
|
||||
filters: [],
|
||||
lensRequestAdapter: undefined,
|
||||
query: unifiedHistogramServicesMock.data.query.queryString.getDefaultQuery(),
|
||||
requestAdapter: undefined,
|
||||
searchSessionId: undefined,
|
||||
timeInterval: 'auto',
|
||||
timeRange: unifiedHistogramServicesMock.data.query.timefilter.timefilter.getTimeDefaults(),
|
||||
topPanelHeight: undefined,
|
||||
totalHitsResult: undefined,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize state with initial values', () => {
|
||||
const stateService = createStateService({
|
||||
services: unifiedHistogramServicesMock,
|
||||
initialState,
|
||||
});
|
||||
let state: UnifiedHistogramState | undefined;
|
||||
stateService.state$.subscribe((s) => (state = s));
|
||||
expect(state).toEqual(initialState);
|
||||
});
|
||||
|
||||
it('should get values from storage if localStorageKeyPrefix is provided', () => {
|
||||
const localStorageKeyPrefix = 'test';
|
||||
createStateService({
|
||||
services: unifiedHistogramServicesMock,
|
||||
localStorageKeyPrefix,
|
||||
initialState,
|
||||
});
|
||||
expect(getChartHidden as jest.Mock).toHaveBeenCalledWith(
|
||||
unifiedHistogramServicesMock.storage,
|
||||
localStorageKeyPrefix
|
||||
);
|
||||
expect(getTopPanelHeight as jest.Mock).toHaveBeenCalledWith(
|
||||
unifiedHistogramServicesMock.storage,
|
||||
localStorageKeyPrefix
|
||||
);
|
||||
expect(getBreakdownField as jest.Mock).toHaveBeenCalledWith(
|
||||
unifiedHistogramServicesMock.storage,
|
||||
localStorageKeyPrefix
|
||||
);
|
||||
});
|
||||
|
||||
it('should not get values from storage if localStorageKeyPrefix is not provided', () => {
|
||||
createStateService({
|
||||
services: unifiedHistogramServicesMock,
|
||||
initialState,
|
||||
});
|
||||
expect(getChartHidden as jest.Mock).not.toHaveBeenCalled();
|
||||
expect(getTopPanelHeight as jest.Mock).not.toHaveBeenCalled();
|
||||
expect(getBreakdownField as jest.Mock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update state', () => {
|
||||
const stateService = createStateService({
|
||||
services: unifiedHistogramServicesMock,
|
||||
initialState,
|
||||
});
|
||||
let state: UnifiedHistogramState | undefined;
|
||||
let newState = initialState;
|
||||
stateService.state$.subscribe((s) => (state = s));
|
||||
expect(state).toEqual(newState);
|
||||
stateService.setChartHidden(true);
|
||||
newState = { ...newState, chartHidden: true };
|
||||
expect(state).toEqual(newState);
|
||||
stateService.setTopPanelHeight(200);
|
||||
newState = { ...newState, topPanelHeight: 200 };
|
||||
expect(state).toEqual(newState);
|
||||
stateService.setBreakdownField('test');
|
||||
newState = { ...newState, breakdownField: 'test' };
|
||||
expect(state).toEqual(newState);
|
||||
stateService.setTimeInterval('test');
|
||||
newState = { ...newState, timeInterval: 'test' };
|
||||
expect(state).toEqual(newState);
|
||||
const requestParams = {
|
||||
dataView: dataViewMock,
|
||||
filters: ['test'] as unknown as Filter[],
|
||||
query: { language: 'kuery', query: 'test' },
|
||||
requestAdapter: undefined,
|
||||
searchSessionId: '321',
|
||||
timeRange: { from: 'now-30m', to: 'now' },
|
||||
};
|
||||
stateService.setRequestParams(requestParams);
|
||||
newState = { ...newState, ...requestParams };
|
||||
expect(state).toEqual(newState);
|
||||
stateService.setLensRequestAdapter(undefined);
|
||||
newState = { ...newState, lensRequestAdapter: undefined };
|
||||
expect(state).toEqual(newState);
|
||||
stateService.setTotalHits({
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.complete,
|
||||
totalHitsResult: 100,
|
||||
});
|
||||
newState = {
|
||||
...newState,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.complete,
|
||||
totalHitsResult: 100,
|
||||
};
|
||||
expect(state).toEqual(newState);
|
||||
});
|
||||
|
||||
it('should update state and save it to storage if localStorageKeyPrefix is provided', () => {
|
||||
const localStorageKeyPrefix = 'test';
|
||||
const stateService = createStateService({
|
||||
services: unifiedHistogramServicesMock,
|
||||
localStorageKeyPrefix,
|
||||
initialState,
|
||||
});
|
||||
let state: UnifiedHistogramState | undefined;
|
||||
stateService.state$.subscribe((s) => (state = s));
|
||||
expect(state).toEqual(initialState);
|
||||
stateService.setChartHidden(true);
|
||||
stateService.setTopPanelHeight(200);
|
||||
stateService.setBreakdownField('test');
|
||||
expect(state).toEqual({
|
||||
...initialState,
|
||||
chartHidden: true,
|
||||
topPanelHeight: 200,
|
||||
breakdownField: 'test',
|
||||
});
|
||||
expect(setChartHidden as jest.Mock).toHaveBeenCalledWith(
|
||||
unifiedHistogramServicesMock.storage,
|
||||
localStorageKeyPrefix,
|
||||
true
|
||||
);
|
||||
expect(setTopPanelHeight as jest.Mock).toHaveBeenCalledWith(
|
||||
unifiedHistogramServicesMock.storage,
|
||||
localStorageKeyPrefix,
|
||||
200
|
||||
);
|
||||
expect(setBreakdownField as jest.Mock).toHaveBeenCalledWith(
|
||||
unifiedHistogramServicesMock.storage,
|
||||
localStorageKeyPrefix,
|
||||
'test'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not save state to storage if localStorageKeyPrefix is not provided', () => {
|
||||
const stateService = createStateService({
|
||||
services: unifiedHistogramServicesMock,
|
||||
initialState,
|
||||
});
|
||||
let state: UnifiedHistogramState | undefined;
|
||||
stateService.state$.subscribe((s) => (state = s));
|
||||
expect(state).toEqual(initialState);
|
||||
stateService.setChartHidden(true);
|
||||
stateService.setTopPanelHeight(200);
|
||||
stateService.setBreakdownField('test');
|
||||
expect(state).toEqual({
|
||||
...initialState,
|
||||
chartHidden: true,
|
||||
topPanelHeight: 200,
|
||||
breakdownField: 'test',
|
||||
});
|
||||
expect(setChartHidden as jest.Mock).not.toHaveBeenCalled();
|
||||
expect(setTopPanelHeight as jest.Mock).not.toHaveBeenCalled();
|
||||
expect(setBreakdownField as jest.Mock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update total hits to loading when the current status is partial', () => {
|
||||
const stateService = createStateService({
|
||||
services: unifiedHistogramServicesMock,
|
||||
initialState: {
|
||||
...initialState,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.partial,
|
||||
},
|
||||
});
|
||||
let state: UnifiedHistogramState | undefined;
|
||||
stateService.state$.subscribe((s) => (state = s));
|
||||
expect(state).toEqual({
|
||||
...initialState,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.partial,
|
||||
});
|
||||
stateService.setTotalHits({
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
|
||||
totalHitsResult: 100,
|
||||
});
|
||||
expect(state).toEqual({
|
||||
...initialState,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.partial,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,248 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import type { RequestAdapter } from '@kbn/inspector-plugin/common';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { UnifiedHistogramFetchStatus } from '../..';
|
||||
import type { UnifiedHistogramServices } from '../../types';
|
||||
import {
|
||||
getBreakdownField,
|
||||
getChartHidden,
|
||||
getTopPanelHeight,
|
||||
setBreakdownField,
|
||||
setChartHidden,
|
||||
setTopPanelHeight,
|
||||
} from '../utils/local_storage_utils';
|
||||
|
||||
/**
|
||||
* The current state of the container
|
||||
*/
|
||||
export interface UnifiedHistogramState {
|
||||
/**
|
||||
* The current field used for the breakdown
|
||||
*/
|
||||
breakdownField: string | undefined;
|
||||
/**
|
||||
* Whether or not the chart is hidden
|
||||
*/
|
||||
chartHidden: boolean;
|
||||
/**
|
||||
* The current data view
|
||||
*/
|
||||
dataView: DataView;
|
||||
/**
|
||||
* The current filters
|
||||
*/
|
||||
filters: Filter[];
|
||||
/**
|
||||
* The current Lens request adapter
|
||||
*/
|
||||
lensRequestAdapter: RequestAdapter | undefined;
|
||||
/**
|
||||
* The current query
|
||||
*/
|
||||
query: Query | AggregateQuery;
|
||||
/**
|
||||
* The current request adapter used for non-Lens requests
|
||||
*/
|
||||
requestAdapter: RequestAdapter | undefined;
|
||||
/**
|
||||
* The current search session ID
|
||||
*/
|
||||
searchSessionId: string | undefined;
|
||||
/**
|
||||
* The current time interval of the chart
|
||||
*/
|
||||
timeInterval: string;
|
||||
/**
|
||||
* The current time range
|
||||
*/
|
||||
timeRange: TimeRange;
|
||||
/**
|
||||
* The current top panel height
|
||||
*/
|
||||
topPanelHeight: number | undefined;
|
||||
/**
|
||||
* The current fetch status of the hits count request
|
||||
*/
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus;
|
||||
/**
|
||||
* The current result of the hits count request
|
||||
*/
|
||||
totalHitsResult: number | Error | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The options used to initialize the comntainer state
|
||||
*/
|
||||
export interface UnifiedHistogramStateOptions {
|
||||
/**
|
||||
* The services required by the Unified Histogram components
|
||||
*/
|
||||
services: UnifiedHistogramServices;
|
||||
/**
|
||||
* The prefix for the keys used in local storage -- leave undefined to avoid using local storage
|
||||
*/
|
||||
localStorageKeyPrefix?: string;
|
||||
/**
|
||||
* The initial state of the container
|
||||
*/
|
||||
initialState: Partial<UnifiedHistogramState> & Pick<UnifiedHistogramState, 'dataView'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service used to manage the state of the container
|
||||
*/
|
||||
export interface UnifiedHistogramStateService {
|
||||
/**
|
||||
* The current state of the container
|
||||
*/
|
||||
state$: Observable<UnifiedHistogramState>;
|
||||
/**
|
||||
* Sets the current chart hidden state
|
||||
*/
|
||||
setChartHidden: (chartHidden: boolean) => void;
|
||||
/**
|
||||
* Sets the current top panel height
|
||||
*/
|
||||
setTopPanelHeight: (topPanelHeight: number | undefined) => void;
|
||||
/**
|
||||
* Sets the current breakdown field
|
||||
*/
|
||||
setBreakdownField: (breakdownField: string | undefined) => void;
|
||||
/**
|
||||
* Sets the current time interval
|
||||
*/
|
||||
setTimeInterval: (timeInterval: string) => void;
|
||||
/**
|
||||
* Sets the current request parameters
|
||||
*/
|
||||
setRequestParams: (requestParams: {
|
||||
dataView?: DataView;
|
||||
filters?: Filter[];
|
||||
query?: Query | AggregateQuery;
|
||||
requestAdapter?: RequestAdapter | undefined;
|
||||
searchSessionId?: string | undefined;
|
||||
timeRange?: TimeRange;
|
||||
}) => void;
|
||||
/**
|
||||
* Sets the current Lens request adapter
|
||||
*/
|
||||
setLensRequestAdapter: (lensRequestAdapter: RequestAdapter | undefined) => void;
|
||||
/**
|
||||
* Sets the current total hits status and result
|
||||
*/
|
||||
setTotalHits: (totalHits: {
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus;
|
||||
totalHitsResult: number | Error | undefined;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const createStateService = (
|
||||
options: UnifiedHistogramStateOptions
|
||||
): UnifiedHistogramStateService => {
|
||||
const { services, localStorageKeyPrefix, initialState } = options;
|
||||
|
||||
let initialChartHidden = false;
|
||||
let initialTopPanelHeight: number | undefined;
|
||||
let initialBreakdownField: string | undefined;
|
||||
|
||||
if (localStorageKeyPrefix) {
|
||||
initialChartHidden = getChartHidden(services.storage, localStorageKeyPrefix) ?? false;
|
||||
initialTopPanelHeight = getTopPanelHeight(services.storage, localStorageKeyPrefix);
|
||||
initialBreakdownField = getBreakdownField(services.storage, localStorageKeyPrefix);
|
||||
}
|
||||
|
||||
const state$ = new BehaviorSubject({
|
||||
breakdownField: initialBreakdownField,
|
||||
chartHidden: initialChartHidden,
|
||||
filters: [],
|
||||
lensRequestAdapter: undefined,
|
||||
query: services.data.query.queryString.getDefaultQuery(),
|
||||
requestAdapter: undefined,
|
||||
searchSessionId: undefined,
|
||||
timeInterval: 'auto',
|
||||
timeRange: services.data.query.timefilter.timefilter.getTimeDefaults(),
|
||||
topPanelHeight: initialTopPanelHeight,
|
||||
totalHitsResult: undefined,
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
|
||||
...initialState,
|
||||
});
|
||||
|
||||
const updateState = (stateUpdate: Partial<UnifiedHistogramState>) => {
|
||||
state$.next({
|
||||
...state$.getValue(),
|
||||
...stateUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
state$,
|
||||
|
||||
setChartHidden: (chartHidden: boolean) => {
|
||||
if (localStorageKeyPrefix) {
|
||||
setChartHidden(services.storage, localStorageKeyPrefix, chartHidden);
|
||||
}
|
||||
|
||||
updateState({ chartHidden });
|
||||
},
|
||||
|
||||
setTopPanelHeight: (topPanelHeight: number | undefined) => {
|
||||
if (localStorageKeyPrefix) {
|
||||
setTopPanelHeight(services.storage, localStorageKeyPrefix, topPanelHeight);
|
||||
}
|
||||
|
||||
updateState({ topPanelHeight });
|
||||
},
|
||||
|
||||
setBreakdownField: (breakdownField: string | undefined) => {
|
||||
if (localStorageKeyPrefix) {
|
||||
setBreakdownField(services.storage, localStorageKeyPrefix, breakdownField);
|
||||
}
|
||||
|
||||
updateState({ breakdownField });
|
||||
},
|
||||
|
||||
setTimeInterval: (timeInterval: string) => {
|
||||
updateState({ timeInterval });
|
||||
},
|
||||
|
||||
setRequestParams: (requestParams: {
|
||||
dataView?: DataView;
|
||||
filters?: Filter[];
|
||||
query?: Query | AggregateQuery;
|
||||
requestAdapter?: RequestAdapter | undefined;
|
||||
searchSessionId?: string | undefined;
|
||||
timeRange?: TimeRange;
|
||||
}) => {
|
||||
updateState(requestParams);
|
||||
},
|
||||
|
||||
setLensRequestAdapter: (lensRequestAdapter: RequestAdapter | undefined) => {
|
||||
updateState({ lensRequestAdapter });
|
||||
},
|
||||
|
||||
setTotalHits: (totalHits: {
|
||||
totalHitsStatus: UnifiedHistogramFetchStatus;
|
||||
totalHitsResult: number | Error | undefined;
|
||||
}) => {
|
||||
// If we have a partial result already, we don't
|
||||
// want to update the total hits back to loading
|
||||
if (
|
||||
state$.getValue().totalHitsStatus === UnifiedHistogramFetchStatus.partial &&
|
||||
totalHits.totalHitsStatus === UnifiedHistogramFetchStatus.loading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateState(totalHits);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import {
|
||||
CHART_HIDDEN_KEY,
|
||||
getBreakdownField,
|
||||
getChartHidden,
|
||||
getTopPanelHeight,
|
||||
HISTOGRAM_BREAKDOWN_FIELD_KEY,
|
||||
HISTOGRAM_HEIGHT_KEY,
|
||||
setBreakdownField,
|
||||
setChartHidden,
|
||||
setTopPanelHeight,
|
||||
} from './local_storage_utils';
|
||||
|
||||
describe('local storage utils', () => {
|
||||
const localStorageKeyPrefix = 'testPrefix';
|
||||
const mockStorage = {
|
||||
get: jest.fn((key: string) => {
|
||||
switch (key) {
|
||||
case `${localStorageKeyPrefix}:${CHART_HIDDEN_KEY}`:
|
||||
return true;
|
||||
case `${localStorageKeyPrefix}:${HISTOGRAM_HEIGHT_KEY}`:
|
||||
return 100;
|
||||
case `${localStorageKeyPrefix}:${HISTOGRAM_BREAKDOWN_FIELD_KEY}`:
|
||||
return 'testField';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
set: jest.fn(),
|
||||
};
|
||||
const storage = mockStorage as unknown as Storage;
|
||||
|
||||
it('should execute get functions correctly', () => {
|
||||
expect(getChartHidden(storage, localStorageKeyPrefix)).toEqual(true);
|
||||
expect(mockStorage.get).toHaveBeenLastCalledWith(
|
||||
`${localStorageKeyPrefix}:${CHART_HIDDEN_KEY}`
|
||||
);
|
||||
expect(getTopPanelHeight(storage, localStorageKeyPrefix)).toEqual(100);
|
||||
expect(mockStorage.get).toHaveBeenLastCalledWith(
|
||||
`${localStorageKeyPrefix}:${HISTOGRAM_HEIGHT_KEY}`
|
||||
);
|
||||
expect(getBreakdownField(storage, localStorageKeyPrefix)).toEqual('testField');
|
||||
expect(mockStorage.get).toHaveBeenLastCalledWith(
|
||||
`${localStorageKeyPrefix}:${HISTOGRAM_BREAKDOWN_FIELD_KEY}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute set functions correctly', () => {
|
||||
setChartHidden(storage, localStorageKeyPrefix, false);
|
||||
expect(mockStorage.set).toHaveBeenLastCalledWith(
|
||||
`${localStorageKeyPrefix}:${CHART_HIDDEN_KEY}`,
|
||||
false
|
||||
);
|
||||
setTopPanelHeight(storage, localStorageKeyPrefix, 200);
|
||||
expect(mockStorage.set).toHaveBeenLastCalledWith(
|
||||
`${localStorageKeyPrefix}:${HISTOGRAM_HEIGHT_KEY}`,
|
||||
200
|
||||
);
|
||||
setBreakdownField(storage, localStorageKeyPrefix, 'testField2');
|
||||
expect(mockStorage.set).toHaveBeenLastCalledWith(
|
||||
`${localStorageKeyPrefix}:${HISTOGRAM_BREAKDOWN_FIELD_KEY}`,
|
||||
'testField2'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
export const CHART_HIDDEN_KEY = 'chartHidden';
|
||||
export const HISTOGRAM_HEIGHT_KEY = 'histogramHeight';
|
||||
export const HISTOGRAM_BREAKDOWN_FIELD_KEY = 'histogramBreakdownField';
|
||||
|
||||
const getLocalStorageKey = (prefix: string, key: string) => `${prefix}:${key}`;
|
||||
|
||||
/**
|
||||
* Get the chart hidden state from local storage
|
||||
*/
|
||||
export const getChartHidden = (
|
||||
storage: Storage,
|
||||
localStorageKeyPrefix: string
|
||||
): boolean | undefined => storage.get(getLocalStorageKey(localStorageKeyPrefix, CHART_HIDDEN_KEY));
|
||||
|
||||
/**
|
||||
* Get the top panel height from local storage
|
||||
*/
|
||||
export const getTopPanelHeight = (
|
||||
storage: Storage,
|
||||
localStorageKeyPrefix: string
|
||||
): number | undefined =>
|
||||
storage.get(getLocalStorageKey(localStorageKeyPrefix, HISTOGRAM_HEIGHT_KEY)) ?? undefined;
|
||||
|
||||
/**
|
||||
* Get the breakdown field from local storage
|
||||
*/
|
||||
export const getBreakdownField = (
|
||||
storage: Storage,
|
||||
localStorageKeyPrefix: string
|
||||
): string | undefined =>
|
||||
storage.get(getLocalStorageKey(localStorageKeyPrefix, HISTOGRAM_BREAKDOWN_FIELD_KEY)) ??
|
||||
undefined;
|
||||
|
||||
/**
|
||||
* Set the chart hidden state in local storage
|
||||
*/
|
||||
export const setChartHidden = (
|
||||
storage: Storage,
|
||||
localStorageKeyPrefix: string,
|
||||
chartHidden: boolean | undefined
|
||||
) => storage.set(getLocalStorageKey(localStorageKeyPrefix, CHART_HIDDEN_KEY), chartHidden);
|
||||
|
||||
/**
|
||||
* Set the top panel height in local storage
|
||||
*/
|
||||
export const setTopPanelHeight = (
|
||||
storage: Storage,
|
||||
localStorageKeyPrefix: string,
|
||||
topPanelHeight: number | undefined
|
||||
) => storage.set(getLocalStorageKey(localStorageKeyPrefix, HISTOGRAM_HEIGHT_KEY), topPanelHeight);
|
||||
|
||||
/**
|
||||
* Set the breakdown field in local storage
|
||||
*/
|
||||
export const setBreakdownField = (
|
||||
storage: Storage,
|
||||
localStorageKeyPrefix: string,
|
||||
breakdownField: string | undefined
|
||||
) =>
|
||||
storage.set(
|
||||
getLocalStorageKey(localStorageKeyPrefix, HISTOGRAM_BREAKDOWN_FIELD_KEY),
|
||||
breakdownField
|
||||
);
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { UnifiedHistogramState } from '../services/state_service';
|
||||
|
||||
export const breakdownFieldSelector = (state: UnifiedHistogramState) => state.breakdownField;
|
||||
export const chartHiddenSelector = (state: UnifiedHistogramState) => state.chartHidden;
|
||||
export const dataViewSelector = (state: UnifiedHistogramState) => state.dataView;
|
||||
export const filtersSelector = (state: UnifiedHistogramState) => state.filters;
|
||||
export const querySelector = (state: UnifiedHistogramState) => state.query;
|
||||
export const requestAdapterSelector = (state: UnifiedHistogramState) => state.requestAdapter;
|
||||
export const searchSessionIdSelector = (state: UnifiedHistogramState) => state.searchSessionId;
|
||||
export const timeIntervalSelector = (state: UnifiedHistogramState) => state.timeInterval;
|
||||
export const timeRangeSelector = (state: UnifiedHistogramState) => state.timeRange;
|
||||
export const topPanelHeightSelector = (state: UnifiedHistogramState) => state.topPanelHeight;
|
||||
export const totalHitsResultSelector = (state: UnifiedHistogramState) => state.totalHitsResult;
|
||||
export const totalHitsStatusSelector = (state: UnifiedHistogramState) => state.totalHitsStatus;
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { distinctUntilChanged, map } from 'rxjs/operators';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useStateSelector = <S, R>(
|
||||
state$: Observable<S> | undefined,
|
||||
selector: (state: S) => R,
|
||||
equalityFn?: (arg0: R, arg1: R) => boolean
|
||||
) => {
|
||||
const [state, setState] = useState<R>();
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = state$
|
||||
?.pipe(map(selector), distinctUntilChanged(equalityFn))
|
||||
.subscribe(setState);
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
}, [equalityFn, selector, state$]);
|
||||
|
||||
return state;
|
||||
};
|
|
@ -8,19 +8,28 @@
|
|||
|
||||
import { UnifiedHistogramPublicPlugin } from './plugin';
|
||||
|
||||
export type { UnifiedHistogramLayoutProps } from './layout';
|
||||
export { UnifiedHistogramLayout } from './layout';
|
||||
export type {
|
||||
UnifiedHistogramUninitializedApi,
|
||||
UnifiedHistogramInitializedApi,
|
||||
UnifiedHistogramApi,
|
||||
UnifiedHistogramContainerProps,
|
||||
UnifiedHistogramInitializeOptions,
|
||||
UnifiedHistogramState,
|
||||
UnifiedHistogramStateOptions,
|
||||
} from './container';
|
||||
export {
|
||||
UnifiedHistogramContainer,
|
||||
getChartHidden,
|
||||
getTopPanelHeight,
|
||||
getBreakdownField,
|
||||
setChartHidden,
|
||||
setTopPanelHeight,
|
||||
setBreakdownField,
|
||||
} from './container';
|
||||
export type {
|
||||
UnifiedHistogramServices,
|
||||
UnifiedHistogramRequestContext,
|
||||
UnifiedHistogramHitsContext,
|
||||
UnifiedHistogramChartContext,
|
||||
UnifiedHistogramBreakdownContext,
|
||||
UnifiedHistogramChartLoadEvent,
|
||||
UnifiedHistogramAdapters,
|
||||
UnifiedHistogramRefetchMessage,
|
||||
UnifiedHistogramInputMessage,
|
||||
UnifiedHistogramInput$,
|
||||
} from './types';
|
||||
export { UnifiedHistogramFetchStatus } from './types';
|
||||
|
||||
|
|
10
src/plugins/unified_histogram/public/layout/index.ts
Normal file
10
src/plugins/unified_histogram/public/layout/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export type { UnifiedHistogramLayoutProps } from './layout';
|
||||
export { UnifiedHistogramLayout } from './layout';
|
|
@ -12,7 +12,7 @@ import React, { useMemo } from 'react';
|
|||
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
|
||||
import { css } from '@emotion/css';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import type { LensEmbeddableInput, TypedLensByValueInput } from '@kbn/lens-plugin/public';
|
||||
import type { LensEmbeddableInput } from '@kbn/lens-plugin/public';
|
||||
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { Chart } from '../chart';
|
||||
import { Panels, PANELS_MODE } from '../panels';
|
||||
|
@ -53,7 +53,7 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown>
|
|||
*/
|
||||
timeRange?: TimeRange;
|
||||
/**
|
||||
* Context object for requests made by unified histogram components -- optional
|
||||
* Context object for requests made by Unified Histogram components -- optional
|
||||
*/
|
||||
request?: UnifiedHistogramRequestContext;
|
||||
/**
|
||||
|
@ -96,14 +96,14 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown>
|
|||
* Input observable
|
||||
*/
|
||||
input$?: UnifiedHistogramInput$;
|
||||
/**
|
||||
* Callback to get the relative time range, useful when passing an absolute time range (e.g. for edit visualization button)
|
||||
*/
|
||||
getRelativeTimeRange?: () => TimeRange;
|
||||
/**
|
||||
* Callback to update the topPanelHeight prop when a resize is triggered
|
||||
*/
|
||||
onTopPanelHeightChange?: (topPanelHeight: number | undefined) => void;
|
||||
/**
|
||||
* Callback to invoke when the user clicks the edit visualization button -- leave undefined to hide the button
|
||||
*/
|
||||
onEditVisualization?: (lensAttributes: TypedLensByValueInput['attributes']) => void;
|
||||
/**
|
||||
* Callback to hide or show the chart -- should set {@link UnifiedHistogramChartContext.hidden} to chartHidden
|
||||
*/
|
||||
|
@ -153,8 +153,8 @@ export const UnifiedHistogramLayout = ({
|
|||
disableTriggers,
|
||||
disabledActions,
|
||||
input$,
|
||||
getRelativeTimeRange,
|
||||
onTopPanelHeightChange,
|
||||
onEditVisualization,
|
||||
onChartHiddenChange,
|
||||
onTimeIntervalChange,
|
||||
onBreakdownFieldChange,
|
||||
|
@ -222,7 +222,7 @@ export const UnifiedHistogramLayout = ({
|
|||
disableTriggers={disableTriggers}
|
||||
disabledActions={disabledActions}
|
||||
input$={input$}
|
||||
onEditVisualization={onEditVisualization}
|
||||
getRelativeTimeRange={getRelativeTimeRange}
|
||||
onResetChartHeight={onResetChartHeight}
|
||||
onChartHiddenChange={onChartHiddenChange}
|
||||
onTimeIntervalChange={onTimeIntervalChange}
|
||||
|
@ -248,6 +248,3 @@ export const UnifiedHistogramLayout = ({
|
|||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default UnifiedHistogramLayout;
|
||||
|
|
33
src/plugins/unified_histogram/public/mocks.ts
Normal file
33
src/plugins/unified_histogram/public/mocks.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import type { UnifiedHistogramInitializedApi, UnifiedHistogramUninitializedApi } from './container';
|
||||
|
||||
export type MockUnifiedHistogramApi = Omit<UnifiedHistogramUninitializedApi, 'initialized'> &
|
||||
Omit<UnifiedHistogramInitializedApi, 'initialized'> & { initialized: boolean };
|
||||
|
||||
export const createMockUnifiedHistogramApi = (
|
||||
{ initialized }: { initialized: boolean } = { initialized: false }
|
||||
) => {
|
||||
const api: MockUnifiedHistogramApi = {
|
||||
initialized,
|
||||
initialize: jest.fn(() => {
|
||||
api.initialized = true;
|
||||
}),
|
||||
state$: new Observable(),
|
||||
setChartHidden: jest.fn(),
|
||||
setTopPanelHeight: jest.fn(),
|
||||
setBreakdownField: jest.fn(),
|
||||
setTimeInterval: jest.fn(),
|
||||
setRequestParams: jest.fn(),
|
||||
setTotalHits: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
return api;
|
||||
};
|
|
@ -15,9 +15,11 @@ import type { DataViewField } from '@kbn/data-views-plugin/public';
|
|||
import type { RequestAdapter } from '@kbn/inspector-plugin/public';
|
||||
import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
|
||||
import type { Subject } from 'rxjs';
|
||||
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
/**
|
||||
* The fetch status of a unified histogram request
|
||||
* The fetch status of a Unified Histogram request
|
||||
*/
|
||||
export enum UnifiedHistogramFetchStatus {
|
||||
uninitialized = 'uninitialized',
|
||||
|
@ -28,14 +30,16 @@ export enum UnifiedHistogramFetchStatus {
|
|||
}
|
||||
|
||||
/**
|
||||
* The services required by the unified histogram components
|
||||
* The services required by the Unified Histogram components
|
||||
*/
|
||||
export interface UnifiedHistogramServices {
|
||||
data: DataPublicPluginStart;
|
||||
theme: Theme;
|
||||
uiActions: UiActionsStart;
|
||||
uiSettings: IUiSettingsClient;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
lens: LensPublicStart;
|
||||
storage: Storage;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -47,6 +51,9 @@ export interface UnifiedHistogramBucketInterval {
|
|||
scale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapters passed up from Lens
|
||||
*/
|
||||
export type UnifiedHistogramAdapters = Partial<DefaultInspectorAdapters>;
|
||||
|
||||
/**
|
||||
|
@ -60,7 +67,7 @@ export interface UnifiedHistogramChartLoadEvent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Context object for requests made by unified histogram components
|
||||
* Context object for requests made by Unified Histogram components
|
||||
*/
|
||||
export interface UnifiedHistogramRequestContext {
|
||||
/**
|
||||
|
|
|
@ -22,6 +22,8 @@
|
|||
"@kbn/datemath",
|
||||
"@kbn/core-ui-settings-browser-mocks",
|
||||
"@kbn/shared-ux-utility",
|
||||
"@kbn/ui-actions-plugin",
|
||||
"@kbn/kibana-utils-plugin",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue