Skip to content

Commit 84d8d53

Browse files
committed
feat(discover): register page context for AI chatbot in classic Discover page
Signed-off-by: tq0905 <1352711780@qq.com>
1 parent 222c5a2 commit 84d8d53

5 files changed

Lines changed: 131 additions & 1 deletion

File tree

src/plugins/discover/opensearch_dashboards.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"visualizations",
1717
"usageCollection"
1818
],
19-
"optionalPlugins": ["home", "share", "explore"],
19+
"optionalPlugins": ["home", "share", "explore", "contextProvider"],
2020
"requiredBundles": [
2121
"home",
2222
"opensearchDashboardsUtils",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { render } from '@testing-library/react';
7+
import { ViewProps } from '../../../../../data_explorer/public';
8+
9+
import DiscoverContext from './index';
10+
11+
// Mock the heavy dependencies so we can focus on the page-context registration logic.
12+
jest.mock('../utils/use_search', () => ({
13+
useSearch: jest.fn(() => ({})),
14+
}));
15+
16+
jest.mock('../../../../../opensearch_dashboards_react/public', () => ({
17+
useOpenSearchDashboards: () => ({ services: {} }),
18+
OpenSearchDashboardsContextProvider: () => null,
19+
}));
20+
21+
const mockUsePageContext = jest.fn();
22+
const mockGetServices = jest.fn();
23+
jest.mock('../../../opensearch_dashboards_services', () => ({
24+
getServices: () => mockGetServices(),
25+
}));
26+
27+
// DiscoverContext is mounted by the router with full ViewProps (AppMountParameters);
28+
// for these unit tests we only care about the page-context side effect, so we cast a
29+
// minimal props object and let the mocked hooks handle the rest.
30+
const renderContext = () =>
31+
render(
32+
<DiscoverContext {...({} as ViewProps)}>
33+
<div>child</div>
34+
</DiscoverContext>
35+
);
36+
37+
describe('DiscoverContext page context registration', () => {
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
});
41+
42+
it('registers page context with the contextProvider hook when available', () => {
43+
mockGetServices.mockReturnValue({
44+
contextProvider: { hooks: { usePageContext: mockUsePageContext } },
45+
});
46+
47+
renderContext();
48+
49+
expect(mockUsePageContext).toHaveBeenCalledTimes(1);
50+
const options = mockUsePageContext.mock.calls[0][0];
51+
expect(options.description).toBe('Discover application page context');
52+
expect(options.categories).toEqual(['page', 'static']);
53+
expect(typeof options.convert).toBe('function');
54+
});
55+
56+
it('maps url state to the expected page context shape via convert', () => {
57+
mockGetServices.mockReturnValue({
58+
contextProvider: { hooks: { usePageContext: mockUsePageContext } },
59+
});
60+
61+
renderContext();
62+
63+
const { convert } = mockUsePageContext.mock.calls[0][0];
64+
65+
const dataset = { id: 'logs-*', title: 'logs-*', type: 'INDEX_PATTERN' };
66+
const result = convert({
67+
_g: { time: { from: 'now-15m', to: 'now' } },
68+
_q: { query: { query: 'status:200', language: 'PPL', dataset } },
69+
});
70+
71+
expect(result).toEqual({
72+
appId: 'discover',
73+
timeRange: { from: 'now-15m', to: 'now' },
74+
query: { query: 'status:200', language: 'PPL' },
75+
dataset,
76+
});
77+
});
78+
79+
it('falls back to safe defaults when url state is empty', () => {
80+
mockGetServices.mockReturnValue({
81+
contextProvider: { hooks: { usePageContext: mockUsePageContext } },
82+
});
83+
84+
renderContext();
85+
86+
const { convert } = mockUsePageContext.mock.calls[0][0];
87+
const result = convert({});
88+
89+
expect(result).toEqual({
90+
appId: 'discover',
91+
timeRange: undefined,
92+
query: { query: '', language: 'kuery' },
93+
dataset: undefined,
94+
});
95+
});
96+
97+
it('does not throw when the contextProvider plugin is unavailable (NOOP fallback)', () => {
98+
mockGetServices.mockReturnValue({});
99+
100+
expect(() => renderContext()).not.toThrow();
101+
expect(mockUsePageContext).not.toHaveBeenCalled();
102+
});
103+
});

src/plugins/discover/public/application/view_components/context/index.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import { useSearch, SearchContextValue } from '../utils/use_search';
1414

1515
const SearchContext = React.createContext<SearchContextValue>({} as SearchContextValue);
1616

17+
// NOOP fallback used when the contextProvider plugin is not available
18+
// (e.g. AI features disabled). Keeps the hook call unconditional.
19+
const NOOP_PAGE_CONTEXT_HOOK = (_options?: any): string => '';
20+
1721
// eslint-disable-next-line import/no-default-export
1822
export default function DiscoverContext({ children }: React.PropsWithChildren<ViewProps>) {
1923
const { services: deServices } = useOpenSearchDashboards<DataExplorerServices>();
@@ -23,6 +27,24 @@ export default function DiscoverContext({ children }: React.PropsWithChildren<Vi
2327
...services,
2428
});
2529

30+
// Register page context so the AI chatbot is aware of the current query,
31+
// language, dataset and time range in classic Discover. Classic Discover
32+
// stores the query one level deeper under `_q.query` (vs `_q` in Explore).
33+
const usePageContext = services.contextProvider?.hooks?.usePageContext || NOOP_PAGE_CONTEXT_HOOK;
34+
usePageContext({
35+
description: 'Discover application page context',
36+
convert: (urlState: any) => ({
37+
appId: 'discover',
38+
timeRange: urlState?._g?.time,
39+
query: {
40+
query: urlState?._q?.query?.query || '',
41+
language: urlState?._q?.query?.language || 'kuery',
42+
},
43+
dataset: urlState?._q?.query?.dataset,
44+
}),
45+
categories: ['page', 'static'],
46+
});
47+
2648
return (
2749
<OpenSearchDashboardsContextProvider services={services}>
2850
<SearchContext.Provider value={searchParams}>{children}</SearchContext.Provider>

src/plugins/discover/public/build_services.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { UrlForwardingStart } from '../../url_forwarding/public';
6060
import { NavigationPublicPluginStart } from '../../navigation/public';
6161
import { DataExplorerServices } from '../../data_explorer/public';
6262
import { Storage } from '../../opensearch_dashboards_utils/public';
63+
import { ContextProviderStart } from '../../context_provider/public';
6364

6465
export interface DiscoverServices {
6566
addBasePath: (path: string) => string;
@@ -86,6 +87,7 @@ export interface DiscoverServices {
8687
visualizations: VisualizationsStart;
8788
storage: Storage;
8889
uiActions: UiActionsStart;
90+
contextProvider?: ContextProviderStart;
8991
}
9092

9193
export function buildServices(
@@ -130,6 +132,7 @@ export function buildServices(
130132
visualizations: plugins.visualizations,
131133
storage,
132134
uiActions: plugins.uiActions,
135+
contextProvider: plugins.contextProvider,
133136
};
134137
}
135138

src/plugins/discover/public/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ declare module '../../share/public' {
7979
}
8080
import { UsageCollectionSetup } from '../../usage_collection/public';
8181
import { ExplorePluginSetup } from '../../explore/public';
82+
import { ContextProviderStart } from '../../context_provider/public';
8283

8384
/**
8485
* @public
@@ -150,6 +151,7 @@ export interface DiscoverStartPlugins {
150151
urlForwarding: UrlForwardingStart;
151152
inspector: InspectorPublicPluginStart;
152153
visualizations: VisualizationsStart;
154+
contextProvider?: ContextProviderStart;
153155
}
154156

155157
/**

0 commit comments

Comments
 (0)