Skip to content

Commit 6c6e833

Browse files
[Discover] Context awareness toolkit (elastic#253183)
## Summary This PR introduces a context awareness toolkit that gives profile extension points a centralized way to trigger actions in the host context (add filters, open tabs, update queries, etc.): ```mermaid flowchart LR subgraph Hosts["Host creates toolkit"] MA["Main App"] EM["Embeddable"] CP["Context Page"] end Hosts --> TK["Toolkit<br/><code>addFilter, openInNewTab,<br/>updateESQLQuery, setExpandedDoc, ...</code>"] TK --> SPM["ScopedProfilesManager"] SPM -->|"{ context, toolkit }"| Prof["Profile Accessors<br/><i>cell renderers, chart section,<br/>doc viewer, cell actions, ...</i>"] Prof -->|"toolkit.actions.*"| Hosts ``` ### Why Profile extension points need to trigger actions in the host app, but there was no central way to do this. Each host (Discover, embeddable, surrounding docs) passed actions down through extension point params, resulting in a lot of duplication and no consistent interface. ### How it works The toolkit gets injected into profile accessors alongside the existing `context` param, so extensions receive `(prev, { context, toolkit })` and can call things like `toolkit.actions.addFilter(...)` directly. Each host constructs its own toolkit implementation: - **Main app** — actions dispatch to tab-scoped Redux thunks - **Embeddable** — actions use dashboard triggers and local state - **Context page** — actions use the filter manager and React state This decouples what profiles can do from how each host implements it, and makes adding new host contexts straightforward. It also lays the foundation for extensible state management for profiles, which can also be centralized and exposed via the toolkit. ### Cleanup - Removes duplicate action implementations across Discover, the embeddable, and surrounding docs - Unifies filter handling into a single thunk that supports both KQL/Lucene and ES|QL WHERE clauses - Simplifies `DiscoverLayout` by moving filter action code out of the component Prep work related to elastic#242987. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [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 - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent 6c88c0e commit 6c6e833

100 files changed

Lines changed: 1828 additions & 800 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/platform/packages/shared/kbn-react-hooks/src/use_stable_callback/use_stable_callback.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10-
import { useEffect, useRef } from 'react';
10+
import { useState } from 'react';
11+
import useLatest from 'react-use/lib/useLatest';
1112

1213
/**
1314
* Accepts a callback and returns a function with a stable identity
1415
* that will always call the latest version of the callback when invoked
1516
*/
16-
export const useStableCallback = <T extends (...args: never[]) => unknown>(fn: T | undefined) => {
17-
const ref = useRef(fn);
17+
export const useStableCallback = <T extends (...args: Parameters<T>) => ReturnType<T>>(fn: T) => {
18+
const latestFn = useLatest(fn);
19+
const [stableFn] = useState(() => {
20+
return ((...args: Parameters<T>) => latestFn.current(...args)) as T;
21+
});
1822

19-
useEffect(() => {
20-
ref.current = fn;
21-
}, [fn]);
22-
23-
return useRef((...args: Parameters<T>) => ref.current?.(...args)).current;
23+
return stableFn;
2424
};

src/platform/plugins/shared/discover/moon.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ dependsOn:
150150
- '@kbn/reporting-public'
151151
- '@kbn/logging'
152152
- '@kbn/logging-mocks'
153+
- '@kbn/react-hooks'
153154
tags:
154155
- plugin
155156
- prod

src/platform/plugins/shared/discover/public/__mocks__/test_provider.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
DiscoverCustomizationProvider,
2323
type DiscoverCustomizationService,
2424
} from '../customizations';
25-
import { type ScopedProfilesManager } from '../context_awareness';
25+
import { EMPTY_CONTEXT_AWARENESS_TOOLKIT, type ScopedProfilesManager } from '../context_awareness';
2626
import type { DiscoverServices } from '../build_services';
2727
import { createDiscoverServicesMock } from './services';
2828
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
@@ -121,7 +121,10 @@ export const DiscoverTestProvider = ({
121121
const scopedProfilesManager = useMemo(
122122
() =>
123123
originalScopedProfilesManager ??
124-
services.profilesManager.createScopedProfilesManager({ scopedEbtManager }),
124+
services.profilesManager.createScopedProfilesManager({
125+
scopedEbtManager,
126+
toolkit: EMPTY_CONTEXT_AWARENESS_TOOLKIT,
127+
}),
125128
[originalScopedProfilesManager, scopedEbtManager, services.profilesManager]
126129
);
127130

src/platform/plugins/shared/discover/public/application/context/context_app.test.tsx

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import userEvent from '@testing-library/user-event';
1414
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
1515
import { setUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/plugin';
1616
import { mockUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/__mocks__';
17-
import { DocViewsRegistry } from '@kbn/unified-doc-viewer';
17+
import { DocViewsRegistry, type DocViewerApi } from '@kbn/unified-doc-viewer';
1818
import { ContextApp, type ContextAppProps } from './context_app';
1919
import { createDiscoverServicesMock } from '../../__mocks__/services';
2020
import { DiscoverTestProvider } from '../../__mocks__/test_provider';
@@ -30,8 +30,6 @@ jest.mock('./hooks/use_context_app_fetch');
3030
jest.mock('./hooks/use_context_app_state');
3131

3232
const services = createDiscoverServicesMock();
33-
const addFiltersMock = jest.spyOn(services.filterManager, 'addFilters');
34-
const updateSavedObjectMock = jest.spyOn(services.dataViews, 'updateSavedObject');
3533
const mockUseContextAppFetch = jest.mocked(useContextAppFetch);
3634
const mockUseContextAppState = jest.mocked(useContextAppState);
3735

@@ -91,15 +89,44 @@ const setDocViewerRegistry = (render: (props: DocViewRenderProps) => React.React
9189
};
9290

9391
describe('ContextApp test', () => {
92+
const addFilterMock = jest.fn();
93+
const setExpandedDocMock = jest.fn();
94+
const docViewerRef = React.createRef<DocViewerApi>();
9495
const defaultProps: ContextAppProps = {
9596
dataView: dataViewMock,
9697
anchorId: 'mocked_anchor_id',
98+
addFilter: addFilterMock,
99+
expandedDoc: undefined,
100+
initialDocViewerTabId: undefined,
101+
docViewerRef,
102+
setExpandedDoc: setExpandedDocMock,
97103
};
98104

99105
const renderComponent = () => {
106+
const StatefulContextApp = () => {
107+
const [expandedDoc, setExpandedDocState] = React.useState(defaultProps.expandedDoc);
108+
const [initialDocViewerTabId, setInitialDocViewerTabId] = React.useState(
109+
defaultProps.initialDocViewerTabId
110+
);
111+
const setExpandedDoc: ContextAppProps['setExpandedDoc'] = (doc, options) => {
112+
setExpandedDocMock(doc, options);
113+
setExpandedDocState(doc);
114+
setInitialDocViewerTabId(options?.initialTabId);
115+
};
116+
117+
return (
118+
<ContextApp
119+
{...defaultProps}
120+
expandedDoc={expandedDoc}
121+
initialDocViewerTabId={initialDocViewerTabId}
122+
setExpandedDoc={setExpandedDoc}
123+
/>
124+
);
125+
};
126+
100127
renderWithKibanaRenderContext(
101128
<DiscoverTestProvider services={services}>
102-
<ContextApp {...defaultProps} />
129+
<StatefulContextApp />
103130
</DiscoverTestProvider>
104131
);
105132
};
@@ -148,7 +175,7 @@ describe('ContextApp test', () => {
148175
);
149176
});
150177

151-
it('should set filters correctly', async () => {
178+
it('should call addFilter from the doc viewer', async () => {
152179
const user = userEvent.setup();
153180

154181
setDocViewerRegistry(({ filter }) => (
@@ -168,15 +195,7 @@ describe('ContextApp test', () => {
168195

169196
await user.click(await screen.findByTestId('docViewFilterButton'));
170197

171-
expect(addFiltersMock).toHaveBeenCalledTimes(1);
172-
expect(addFiltersMock).toHaveBeenCalledWith([
173-
{
174-
$state: { store: 'appState' },
175-
meta: { alias: null, disabled: false, index: 'the-data-view-id', negate: false },
176-
query: { match_phrase: { extension: 'jpg' } },
177-
},
178-
]);
179-
expect(updateSavedObjectMock).toHaveBeenCalledTimes(1);
180-
expect(updateSavedObjectMock).toHaveBeenCalledWith(dataViewMock, 0, true);
198+
expect(addFilterMock).toHaveBeenCalledTimes(1);
199+
expect(addFilterMock).toHaveBeenCalledWith('extension', 'jpg', '+');
181200
});
182201
});

src/platform/plugins/shared/discover/public/application/context/context_app.tsx

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,17 @@ import {
2020
import { css } from '@emotion/react';
2121
import { cloneDeep } from 'lodash';
2222
import { useMemoCss } from '@kbn/css-utils/public/use_memo_css';
23-
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
23+
import type { DataView } from '@kbn/data-views-plugin/public';
2424
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
25-
import { generateFilters } from '@kbn/data-plugin/public';
2625
import { i18n } from '@kbn/i18n';
2726
import { SORT_DEFAULT_ORDER_SETTING } from '@kbn/discover-utils';
2827
import type { UseColumnsProps } from '@kbn/unified-data-table';
29-
import { popularizeField, useColumns } from '@kbn/unified-data-table';
28+
import { useColumns } from '@kbn/unified-data-table';
3029
import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
3130
import type { DiscoverGridSettings } from '@kbn/saved-search-plugin/common';
3231
import { kbnFullBodyHeightCss } from '@kbn/css-utils/public/full_body_height_css';
32+
import type { DataTableRecord } from '@kbn/discover-utils/types';
33+
import type { DocViewerApi } from '@kbn/unified-doc-viewer';
3334
import { ContextErrorMessage } from './components/context_error_message';
3435
import { LoadingStatus } from './services/context_query_state';
3536
import type { AppState, GlobalState } from './services/context_state';
@@ -48,23 +49,29 @@ export interface ContextAppProps {
4849
dataView: DataView;
4950
anchorId: string;
5051
referrer?: string;
52+
addFilter: DocViewFilterFn;
53+
expandedDoc: DataTableRecord | undefined;
54+
initialDocViewerTabId: string | undefined;
55+
docViewerRef: React.RefObject<DocViewerApi>;
56+
setExpandedDoc: (doc: DataTableRecord | undefined, options?: { initialTabId?: string }) => void;
5157
}
5258

53-
export const ContextApp = ({ dataView, anchorId, referrer }: ContextAppProps) => {
59+
export const ContextApp = ({
60+
dataView,
61+
anchorId,
62+
referrer,
63+
addFilter,
64+
expandedDoc,
65+
initialDocViewerTabId,
66+
docViewerRef,
67+
setExpandedDoc,
68+
}: ContextAppProps) => {
5469
const styles = useMemoCss(componentStyles);
5570

5671
const services = useDiscoverServices();
5772
const { scopedEBTManager } = useScopedServices();
58-
const {
59-
locator,
60-
uiSettings,
61-
capabilities,
62-
dataViews,
63-
navigation,
64-
filterManager,
65-
core,
66-
fieldsMetadata,
67-
} = services;
73+
const { locator, uiSettings, capabilities, dataViews, navigation, core, fieldsMetadata } =
74+
services;
6875

6976
/**
7077
* Context app state
@@ -199,23 +206,6 @@ export const ContextApp = ({ dataView, anchorId, referrer }: ContextAppProps) =>
199206
]
200207
);
201208

202-
const addFilter = useCallback(
203-
async (field: DataViewField | string, values: unknown, operation: '+' | '-') => {
204-
const newFilters = generateFilters(filterManager, field, values, operation, dataView);
205-
filterManager.addFilters(newFilters);
206-
if (dataViews) {
207-
const fieldName = typeof field === 'string' ? field : field.name;
208-
await popularizeField(dataView, fieldName, dataViews, capabilities);
209-
void scopedEBTManager.trackFilterAddition({
210-
fieldName: fieldName === '_exists_' ? String(values) : fieldName,
211-
filterOperation: fieldName === '_exists_' ? '_exists_' : operation,
212-
fieldsMetadata,
213-
});
214-
}
215-
},
216-
[filterManager, dataView, dataViews, capabilities, scopedEBTManager, fieldsMetadata]
217-
);
218-
219209
const onAddColumnWithTracking = useCallback(
220210
(columnName: string) => {
221211
onAddColumn(columnName);
@@ -289,7 +279,11 @@ export const ContextApp = ({ dataView, anchorId, referrer }: ContextAppProps) =>
289279
predecessorCount={appState.predecessorCount}
290280
successorCount={appState.successorCount}
291281
setAppState={stateContainer.setAppState}
292-
addFilter={addFilter as DocViewFilterFn}
282+
addFilter={addFilter}
283+
expandedDoc={expandedDoc}
284+
initialDocViewerTabId={initialDocViewerTabId}
285+
docViewerRef={docViewerRef}
286+
setExpandedDoc={setExpandedDoc}
293287
rows={rows}
294288
predecessors={fetchedState.predecessors}
295289
successors={fetchedState.successors}

src/platform/plugins/shared/discover/public/application/context/context_app_content.tsx

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type { FC } from 'react';
11-
import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react';
11+
import React, { Fragment, useCallback, useMemo } from 'react';
1212
import { EuiSpacer, useEuiPaddingSize } from '@elastic/eui';
1313
import { css } from '@emotion/react';
1414
import type { DataView } from '@kbn/data-views-plugin/public';
@@ -67,6 +67,10 @@ export interface ContextAppContentProps {
6767
interceptedWarnings: SearchResponseWarning[];
6868
setAppState: (newState: Partial<AppState>) => void;
6969
addFilter: DocViewFilterFn;
70+
expandedDoc: DataTableRecord | undefined;
71+
initialDocViewerTabId: string | undefined;
72+
docViewerRef: React.RefObject<DocViewerApi>;
73+
setExpandedDoc: (doc: DataTableRecord | undefined, options?: { initialTabId?: string }) => void;
7074
}
7175

7276
const controlColumnIds = ['openDetails'];
@@ -95,25 +99,14 @@ export function ContextAppContent({
9599
interceptedWarnings,
96100
setAppState,
97101
addFilter,
102+
expandedDoc,
103+
initialDocViewerTabId,
104+
docViewerRef,
105+
setExpandedDoc,
98106
}: ContextAppContentProps) {
99107
const { uiSettings: config, uiActions } = useDiscoverServices();
100108
const services = useDiscoverServices();
101109

102-
const [expandedDoc, setExpandedDoc] = useState<DataTableRecord | undefined>();
103-
const [initialTabId, setInitialTabId] = useState<string | undefined>(undefined);
104-
const docViewerRef = useRef<DocViewerApi>(null);
105-
106-
const setExpandedDocWithInitialTab = useCallback(
107-
(doc: DataTableRecord | undefined, options?: { initialTabId?: string }) => {
108-
setExpandedDoc(doc);
109-
setInitialTabId(options?.initialTabId);
110-
if (options?.initialTabId) {
111-
docViewerRef.current?.setSelectedTabId(options.initialTabId);
112-
}
113-
},
114-
[]
115-
);
116-
117110
const isAnchorLoading =
118111
anchorStatus === LoadingStatus.LOADING || anchorStatus === LoadingStatus.UNINITIALIZED;
119112
const arePredecessorsLoading =
@@ -152,12 +145,20 @@ export function ContextAppContent({
152145
onRemoveColumn={onRemoveColumn}
153146
onAddColumn={onAddColumn}
154147
onClose={() => setExpandedDoc(undefined)}
155-
initialTabId={initialTabId}
156-
setExpandedDoc={setExpandedDocWithInitialTab}
148+
initialTabId={initialDocViewerTabId}
149+
setExpandedDoc={setExpandedDoc}
157150
docViewerRef={docViewerRef}
158151
/>
159152
),
160-
[addFilter, dataView, onAddColumn, onRemoveColumn, setExpandedDocWithInitialTab, initialTabId]
153+
[
154+
addFilter,
155+
dataView,
156+
docViewerRef,
157+
initialDocViewerTabId,
158+
onAddColumn,
159+
onRemoveColumn,
160+
setExpandedDoc,
161+
]
161162
);
162163

163164
const onResize = useCallback<NonNullable<UnifiedDataTableProps['onResize']>>(
@@ -172,7 +173,6 @@ export function ContextAppContent({
172173
const cellRenderers = useMemo(() => {
173174
const getCellRenderers = getCellRenderersAccessor(() => ({}));
174175
return getCellRenderers({
175-
actions: { addFilter },
176176
dataView,
177177
density: getDataGridDensity(services.storage, 'discover'),
178178
rowHeight: getRowHeight({
@@ -181,7 +181,7 @@ export function ContextAppContent({
181181
configRowHeight,
182182
}),
183183
});
184-
}, [addFilter, configRowHeight, dataView, getCellRenderersAccessor, services.storage]);
184+
}, [configRowHeight, dataView, getCellRenderersAccessor, services.storage]);
185185

186186
const dataSource = useMemo(() => createDataSource({ dataView, query: undefined }), [dataView]);
187187
const { filters } = useQuerySubscriber({ data: services.data });
@@ -236,7 +236,7 @@ export function ContextAppContent({
236236
isPaginationEnabled={false}
237237
rowsPerPageState={getDefaultRowsPerPage(services.uiSettings)}
238238
controlColumnIds={controlColumnIds}
239-
setExpandedDoc={setExpandedDocWithInitialTab}
239+
setExpandedDoc={setExpandedDoc}
240240
onFilter={addFilter}
241241
onSetColumns={onSetColumns}
242242
configRowHeight={configRowHeight}

0 commit comments

Comments
 (0)