[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:
Davis McPhee 2023-02-07 15:14:06 -04:00 committed by GitHub
parent e143c8eaa6
commit 02af928026
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 2445 additions and 795 deletions

View file

@ -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]

View file

@ -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;

View file

@ -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();

View file

@ -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();
});

View file

@ -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>
);
};

View file

@ -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);
});

View file

@ -239,7 +239,6 @@ export function DiscoverLayout({
setExpandedDoc={setExpandedDoc}
savedSearch={savedSearch}
stateContainer={stateContainer}
isTimeBased={isTimeBased}
columns={currentColumns}
viewMode={viewMode}
onAddFilter={onAddFilter as DocViewFilterFn}

View file

@ -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();
});
});
});

View file

@ -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)
);
};

View file

@ -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,

View file

@ -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>
);
```

View file

@ -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;

View file

@ -25,6 +25,7 @@ const fields = [
filterable: true,
aggregatable: true,
sortable: true,
visualizable: true,
},
{
name: 'message',

View file

@ -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;

View file

@ -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', () => {

View file

@ -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;

View file

@ -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 () => {

View file

@ -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}

View file

@ -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);

View file

@ -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;

View file

@ -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', () => {

View file

@ -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,

View file

@ -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,

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import 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();
});
});

View file

@ -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;
};

View file

@ -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', () => {

View file

@ -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 = ({

View file

@ -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', () => {

View file

@ -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(() => {

View file

@ -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', () => {

View file

@ -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 = ({

View file

@ -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;
};

View file

@ -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', () => ({

View file

@ -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,

View file

@ -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';

View file

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

View file

@ -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';

View file

@ -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 = ({

View file

@ -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';

View file

@ -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,

View file

@ -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;

View file

@ -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);
});
});

View 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;

View file

@ -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);
});
});

View file

@ -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,
};
};

View file

@ -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"

View file

@ -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,
});
});
});

View file

@ -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);
},
};
};

View file

@ -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'
);
});
});

View file

@ -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
);

View file

@ -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;

View file

@ -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;
};

View file

@ -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';

View 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';

View file

@ -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;

View 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;
};

View file

@ -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 {
/**

View file

@ -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/**/*",