diff --git a/packages/kbn-discover-utils/src/utils/build_data_record.test.ts b/packages/kbn-discover-utils/src/utils/build_data_record.test.ts index ad486aba543a..e6046d4f5977 100644 --- a/packages/kbn-discover-utils/src/utils/build_data_record.test.ts +++ b/packages/kbn-discover-utils/src/utils/build_data_record.test.ts @@ -30,5 +30,17 @@ describe('Data table record utils', () => { expect(doc).toHaveProperty('isAnchor'); }); }); + + test('should support processing each record', () => { + const result = buildDataTableRecordList(esHitsMock, dataViewMock, { + processRecord: (record) => ({ ...record, id: 'custom-id' }), + }); + result.forEach((doc) => { + expect(doc).toHaveProperty('id', 'custom-id'); + expect(doc).toHaveProperty('raw'); + expect(doc).toHaveProperty('flattened'); + expect(doc).toHaveProperty('isAnchor'); + }); + }); }); }); diff --git a/packages/kbn-discover-utils/src/utils/build_data_record.ts b/packages/kbn-discover-utils/src/utils/build_data_record.ts index 43adf7b9c8b6..9769201e94aa 100644 --- a/packages/kbn-discover-utils/src/utils/build_data_record.ts +++ b/packages/kbn-discover-utils/src/utils/build_data_record.ts @@ -35,9 +35,13 @@ export function buildDataTableRecord( * @param docs Array of documents returned from Elasticsearch * @param dataView this current data view */ -export function buildDataTableRecordList( +export function buildDataTableRecordList( docs: EsHitRecord[], - dataView?: DataView + dataView?: DataView, + { processRecord }: { processRecord?: (record: DataTableRecord) => T } = {} ): DataTableRecord[] { - return docs.map((doc) => buildDataTableRecord(doc, dataView)); + return docs.map((doc) => { + const record = buildDataTableRecord(doc, dataView); + return processRecord ? processRecord(record) : record; + }); } diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index e4e2b71de8e7..f75755319a11 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -41,6 +41,7 @@ import { SearchSourceDependencies } from '@kbn/data-plugin/common'; import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import { urlTrackerMock } from './url_tracker.mock'; import { createElement } from 'react'; +import { createContextAwarenessMocks } from '../context_awareness/__mocks__'; export function createDiscoverServicesMock(): DiscoverServices { const dataPlugin = dataPluginMock.createStartContract(); @@ -137,6 +138,7 @@ export function createDiscoverServicesMock(): DiscoverServices { ...uiSettingsMock, }; + const { profilesManagerMock } = createContextAwarenessMocks(); const theme = themeServiceMock.createSetupContract({ darkMode: false }); corePluginMock.theme = theme; @@ -236,6 +238,7 @@ export function createDiscoverServicesMock(): DiscoverServices { contextLocator: { getRedirectUrl: jest.fn(() => '') }, singleDocLocator: { getRedirectUrl: jest.fn(() => '') }, urlTracker: urlTrackerMock, + profilesManager: profilesManagerMock, setHeaderActionMenu: jest.fn(), } as unknown as DiscoverServices; } diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx index ae4b05f495cf..85c2dd581eec 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx @@ -31,6 +31,7 @@ const customisationService = createCustomizationService(); async function mountComponent(fetchStatus: FetchStatus, hits: EsHitRecord[]) { const services = discoverServiceMock; + services.data.query.timefilter.timefilter.getTime = () => { return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }; }; @@ -69,6 +70,10 @@ async function mountComponent(fetchStatus: FetchStatus, hits: EsHitRecord[]) { } describe('Discover documents layout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('render loading when loading and no documents', async () => { const component = await mountComponent(FetchStatus.LOADING, []); expect(component.find('.dscDocuments__loading').exists()).toBeTruthy(); @@ -131,4 +136,22 @@ describe('Discover documents layout', () => { expect(discoverGridComponent.prop('externalCustomRenderers')).toBeDefined(); expect(discoverGridComponent.prop('customGridColumnsConfiguration')).toBeDefined(); }); + + describe('context awareness', () => { + it('should pass cell renderers from profile', async () => { + customisationService.set({ + id: 'data_table', + logsEnabled: true, + }); + await discoverServiceMock.profilesManager.resolveRootProfile({ solutionNavId: 'test' }); + const component = await mountComponent(FetchStatus.COMPLETE, esHitsMock); + const discoverGridComponent = component.find(DiscoverGrid); + expect(discoverGridComponent.exists()).toBeTruthy(); + expect(Object.keys(discoverGridComponent.prop('externalCustomRenderers')!)).toEqual([ + 'content', + 'resource', + 'rootProfile', + ]); + }); + }); }); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index ad46b7f3db65..caba229e9137 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -68,6 +68,7 @@ import { onResizeGridColumn } from '../../../../utils/on_resize_grid_column'; import { useContextualGridCustomisations } from '../../hooks/grid_customisations'; import { useIsEsqlMode } from '../../hooks/use_is_esql_mode'; import { useAdditionalFieldGroups } from '../../hooks/sidebar/use_additional_field_groups'; +import { useProfileAccessor } from '../../../../context_awareness'; const containerStyles = css` position: relative; @@ -263,6 +264,12 @@ function DiscoverDocumentsComponent({ useContextualGridCustomisations() || {}; const additionalFieldGroups = useAdditionalFieldGroups(); + const getCellRenderersAccessor = useProfileAccessor('getCellRenderers'); + const cellRenderers = useMemo(() => { + const getCellRenderers = getCellRenderersAccessor(() => customCellRenderer ?? {}); + return getCellRenderers(); + }, [customCellRenderer, getCellRenderersAccessor]); + const documents = useObservable(stateContainer.dataState.data$.documents$); const callouts = useMemo( @@ -373,66 +380,64 @@ function DiscoverDocumentsComponent({ )} {!isLegacy && ( - <> -
- - - -
- +
+ + + +
)} diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_all.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_all.ts index 410d1d468275..aed3e6f9a022 100644 --- a/src/plugins/discover/public/application/main/data_fetching/fetch_all.ts +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_all.ts @@ -64,7 +64,7 @@ export function fetchAll( savedSearch, abortController, } = fetchDeps; - const { data } = services; + const { data, expressions, profilesManager } = services; const searchSource = savedSearch.searchSource.createChild(); try { @@ -100,14 +100,15 @@ export function fetchAll( // Start fetching all required requests const response = isEsqlQuery - ? fetchEsql( + ? fetchEsql({ query, dataView, - data, - services.expressions, + abortSignal: abortController.signal, inspectorAdapters, - abortController.signal - ) + data, + expressions, + profilesManager, + }) : fetchDocuments(searchSource, fetchDeps); const fetchType = isEsqlQuery ? 'fetchTextBased' : 'fetchDocuments'; const startTime = window.performance.now(); diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_documents.test.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_documents.test.ts index 7abc2d2744a6..be1fddf64e87 100644 --- a/src/plugins/discover/public/application/main/data_fetching/fetch_documents.test.ts +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_documents.test.ts @@ -30,6 +30,10 @@ const getDeps = () => } as unknown as FetchDeps); describe('test fetchDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('resolves with returned documents', async () => { const hits = [ { _id: '1', foo: 'bar' }, @@ -38,10 +42,17 @@ describe('test fetchDocuments', () => { const documents = hits.map((hit) => buildDataTableRecord(hit, dataViewMock)); savedSearchMock.searchSource.fetch$ = () => of({ rawResponse: { hits: { hits } } } as IKibanaSearchResponse>); + const resolveDocumentProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDocumentProfile' + ); expect(await fetchDocuments(savedSearchMock.searchSource, getDeps())).toEqual({ interceptedWarnings: [], records: documents, }); + expect(resolveDocumentProfileSpy).toHaveBeenCalledTimes(2); + expect(resolveDocumentProfileSpy).toHaveBeenCalledWith({ record: documents[0] }); + expect(resolveDocumentProfileSpy).toHaveBeenCalledWith({ record: documents[1] }); }); test('rejects on query failure', async () => { diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_documents.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_documents.ts index 414d4b3a3658..4ffdd211c0e5 100644 --- a/src/plugins/discover/public/application/main/data_fetching/fetch_documents.ts +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_documents.ts @@ -67,7 +67,9 @@ export const fetchDocuments = ( .pipe( filter((res) => !isRunningResponse(res)), map((res) => { - return buildDataTableRecordList(res.rawResponse.hits.hits as EsHitRecord[], dataView); + return buildDataTableRecordList(res.rawResponse.hits.hits as EsHitRecord[], dataView, { + processRecord: (record) => services.profilesManager.resolveDocumentProfile({ record }), + }); }) ); diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_esql.test.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.test.ts new file mode 100644 index 000000000000..6546ae8ffaf2 --- /dev/null +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { EsHitRecord } from '@kbn/discover-utils'; +import type { ExecutionContract } from '@kbn/expressions-plugin/common'; +import { RequestAdapter } from '@kbn/inspector-plugin/common'; +import { of } from 'rxjs'; +import { dataViewWithTimefieldMock } from '../../../__mocks__/data_view_with_timefield'; +import { discoverServiceMock } from '../../../__mocks__/services'; +import { fetchEsql } from './fetch_esql'; + +describe('fetchEsql', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves with returned records', async () => { + const hits = [ + { _id: '1', foo: 'bar' }, + { _id: '2', foo: 'baz' }, + ] as unknown as EsHitRecord[]; + const records = hits.map((hit, i) => ({ + id: String(i), + raw: hit, + flattened: hit, + })); + const expressionsExecuteSpy = jest.spyOn(discoverServiceMock.expressions, 'execute'); + expressionsExecuteSpy.mockReturnValueOnce({ + cancel: jest.fn(), + getData: jest.fn(() => + of({ + result: { + columns: ['_id', 'foo'], + rows: hits, + }, + }) + ), + } as unknown as ExecutionContract); + const resolveDocumentProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDocumentProfile' + ); + expect( + await fetchEsql({ + query: { esql: 'from *' }, + dataView: dataViewWithTimefieldMock, + inspectorAdapters: { requests: new RequestAdapter() }, + data: discoverServiceMock.data, + expressions: discoverServiceMock.expressions, + profilesManager: discoverServiceMock.profilesManager, + }) + ).toEqual({ + records, + esqlQueryColumns: ['_id', 'foo'], + esqlHeaderWarning: undefined, + }); + expect(resolveDocumentProfileSpy).toHaveBeenCalledTimes(2); + expect(resolveDocumentProfileSpy).toHaveBeenCalledWith({ record: records[0] }); + expect(resolveDocumentProfileSpy).toHaveBeenCalledWith({ record: records[1] }); + }); +}); diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts index 3aba795d2692..3f54984ae3d3 100644 --- a/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts @@ -8,15 +8,16 @@ import { pluck } from 'rxjs'; import { lastValueFrom } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { Query, AggregateQuery, Filter } from '@kbn/es-query'; +import type { Query, AggregateQuery, Filter } from '@kbn/es-query'; import type { Adapters } from '@kbn/inspector-plugin/common'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { Datatable } from '@kbn/expressions-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common'; -import type { DataTableRecord } from '@kbn/discover-utils/types'; +import type { DataTableRecord, EsHitRecord } from '@kbn/discover-utils'; import type { RecordsFetchResponse } from '../../types'; +import type { ProfilesManager } from '../../../context_awareness'; interface EsqlErrorResponse { error: { @@ -25,16 +26,27 @@ interface EsqlErrorResponse { type: 'error'; } -export function fetchEsql( - query: Query | AggregateQuery, - dataView: DataView, - data: DataPublicPluginStart, - expressions: ExpressionsStart, - inspectorAdapters: Adapters, - abortSignal?: AbortSignal, - filters?: Filter[], - inputQuery?: Query -): Promise { +export function fetchEsql({ + query, + inputQuery, + filters, + dataView, + abortSignal, + inspectorAdapters, + data, + expressions, + profilesManager, +}: { + query: Query | AggregateQuery; + inputQuery?: Query; + filters?: Filter[]; + dataView: DataView; + abortSignal?: AbortSignal; + inspectorAdapters: Adapters; + data: DataPublicPluginStart; + expressions: ExpressionsStart; + profilesManager: ProfilesManager; +}): Promise { const timeRange = data.query.timefilter.timefilter.getTime(); return textBasedQueryStateToAstWithValidation({ filters, @@ -69,12 +81,14 @@ export function fetchEsql( const rows = table?.rows ?? []; esqlQueryColumns = table?.columns ?? undefined; esqlHeaderWarning = table.warning ?? undefined; - finalData = rows.map((row: Record, idx: number) => { - return { + finalData = rows.map((row, idx) => { + const record: DataTableRecord = { id: String(idx), - raw: row, + raw: row as EsHitRecord, flattened: row, - } as unknown as DataTableRecord; + }; + + return profilesManager.resolveDocumentProfile({ record }); }); } }); @@ -91,7 +105,7 @@ export function fetchEsql( }); } return { - records: [] as DataTableRecord[], + records: [], esqlQueryColumns: [], esqlHeaderWarning: undefined, }; diff --git a/src/plugins/discover/public/application/main/discover_main_route.test.tsx b/src/plugins/discover/public/application/main/discover_main_route.test.tsx index 496bb91f92cf..b49abb2fe668 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.test.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.test.tsx @@ -40,9 +40,22 @@ jest.mock('./discover_main_app', () => { }; }); +let mockRootProfileLoading = false; + +jest.mock('../../context_awareness', () => { + const originalModule = jest.requireActual('../../context_awareness'); + return { + ...originalModule, + useRootProfile: () => ({ + rootProfileLoading: mockRootProfileLoading, + }), + }; +}); + describe('DiscoverMainRoute', () => { beforeEach(() => { mockCustomizationService = createCustomizationService(); + mockRootProfileLoading = false; }); test('renders the main app when hasESData=true & hasUserDataView=true ', async () => { @@ -97,6 +110,20 @@ describe('DiscoverMainRoute', () => { }); }); + test('renders LoadingIndicator while root profile is loading', async () => { + mockRootProfileLoading = true; + const component = mountComponent(true, true); + await waitFor(() => { + component.update(); + expect(component.find(DiscoverMainApp).exists()).toBe(false); + }); + mockRootProfileLoading = false; + await waitFor(() => { + component.setProps({}).update(); + expect(component.find(DiscoverMainApp).exists()).toBe(true); + }); + }); + test('should pass hideNavMenuItems=true to DiscoverTopNavInline while loading', async () => { const component = mountComponent(true, true); expect(component.find(DiscoverTopNavInline).prop('hideNavMenuItems')).toBe(true); diff --git a/src/plugins/discover/public/application/main/discover_main_route.tsx b/src/plugins/discover/public/application/main/discover_main_route.tsx index 560f4cb03535..f37487b6b93b 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.tsx @@ -40,6 +40,7 @@ import { import { DiscoverTopNavInline } from './components/top_nav/discover_topnav_inline'; import { DiscoverStateContainer, LoadParams } from './state_management/discover_state'; import { DataSourceType, isDataSourceType } from '../../../common/data_sources'; +import { useRootProfile } from '../../context_awareness'; const DiscoverMainAppMemoized = memo(DiscoverMainApp); @@ -338,11 +339,14 @@ export function DiscoverMainRoute({ stateContainer, ]); + const { solutionNavId } = customizationContext; + const { rootProfileLoading } = useRootProfile({ solutionNavId }); + if (error) { return ; } - if (!customizationService) { + if (!customizationService || rootProfileLoading) { return loadingIndicator; } diff --git a/src/plugins/discover/public/application/main/state_management/discover_data_state_container.test.ts b/src/plugins/discover/public/application/main/state_management/discover_data_state_container.test.ts index 67586670c01c..05668e0406f9 100644 --- a/src/plugins/discover/public/application/main/state_management/discover_data_state_container.test.ts +++ b/src/plugins/discover/public/application/main/state_management/discover_data_state_container.test.ts @@ -26,6 +26,10 @@ jest.mock('@kbn/ebt-tools', () => ({ const mockFetchDocuments = fetchDocuments as unknown as jest.MockedFunction; describe('test getDataStateContainer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('return is valid', async () => { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); const dataState = stateContainer.dataState; @@ -35,6 +39,7 @@ describe('test getDataStateContainer', () => { expect(dataState.data$.documents$.getValue().fetchStatus).toBe(FetchStatus.LOADING); expect(dataState.data$.totalHits$.getValue().fetchStatus).toBe(FetchStatus.LOADING); }); + test('refetch$ triggers a search', async () => { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); jest.spyOn(stateContainer.searchSessionManager, 'getNextSearchSessionId'); @@ -46,10 +51,15 @@ describe('test getDataStateContainer', () => { discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; }); - const dataState = stateContainer.dataState; + const dataState = stateContainer.dataState; const unsubscribe = dataState.subscribe(); + const resolveDataSourceProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDataSourceProfile' + ); + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); expect(dataState.data$.totalHits$.value.result).toBe(undefined); expect(dataState.data$.documents$.value.result).toEqual(undefined); @@ -58,6 +68,12 @@ describe('test getDataStateContainer', () => { expect(dataState.data$.main$.value.fetchStatus).toBe('complete'); }); + expect(resolveDataSourceProfileSpy).toHaveBeenCalledTimes(1); + expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({ + dataSource: stateContainer.appState.get().dataSource, + dataView: stateContainer.savedSearchState.getState().searchSource.getField('index'), + query: stateContainer.appState.get().query, + }); expect(dataState.data$.totalHits$.value.result).toBe(0); expect(dataState.data$.documents$.value.result).toEqual([]); @@ -117,9 +133,13 @@ describe('test getDataStateContainer', () => { ).not.toHaveBeenCalled(); const dataState = stateContainer.dataState; - const unsubscribe = dataState.subscribe(); + const resolveDataSourceProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDataSourceProfile' + ); + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); expect(dataState.data$.documents$.value.result).toEqual(initialRecords); let hasLoadingMoreStarted = false; @@ -131,6 +151,7 @@ describe('test getDataStateContainer', () => { } if (hasLoadingMoreStarted && value.fetchStatus === FetchStatus.COMPLETE) { + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); expect(value.result).toEqual([...initialRecords, ...moreRecords]); // it uses the same current search session id expect( diff --git a/src/plugins/discover/public/application/main/state_management/discover_data_state_container.ts b/src/plugins/discover/public/application/main/state_management/discover_data_state_container.ts index 71ad2ed87e79..aaa1f6c15c0f 100644 --- a/src/plugins/discover/public/application/main/state_management/discover_data_state_container.ts +++ b/src/plugins/discover/public/application/main/state_management/discover_data_state_container.ts @@ -152,7 +152,7 @@ export function getDataStateContainer({ getSavedSearch: () => SavedSearch; setDataView: (dataView: DataView) => void; }): DiscoverDataStateContainer { - const { data, uiSettings, toastNotifications } = services; + const { data, uiSettings, toastNotifications, profilesManager } = services; const { timefilter } = data.query.timefilter; const inspectorAdapters = { requests: new RequestAdapter() }; @@ -249,6 +249,12 @@ export function getDataStateContainer({ return; } + await profilesManager.resolveDataSourceProfile({ + dataSource: getAppState().dataSource, + dataView: getSavedSearch().searchSource.getField('index'), + query: getAppState().query, + }); + abortController = new AbortController(); const prevAutoRefreshDone = autoRefreshDone; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index e3524dcdf115..519d6a36fb52 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -7,8 +7,7 @@ */ import { History } from 'history'; - -import { +import type { Capabilities, ChromeStart, CoreStart, @@ -24,28 +23,26 @@ import { AppMountParameters, ScopedHistory, } from '@kbn/core/public'; -import { +import type { FilterManager, TimefilterContract, DataViewsContract, DataPublicPluginStart, } from '@kbn/data-plugin/public'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; -import { Start as InspectorPublicPluginStart } from '@kbn/inspector-plugin/public'; -import { SharePluginStart } from '@kbn/share-plugin/public'; -import { ChartsPluginStart } from '@kbn/charts-plugin/public'; -import { UiCounterMetricType } from '@kbn/analytics'; +import type { Start as InspectorPublicPluginStart } from '@kbn/inspector-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; +import type { UiCounterMetricType } from '@kbn/analytics'; import { Storage } from '@kbn/kibana-utils-plugin/public'; - -import { UrlForwardingStart } from '@kbn/url-forwarding-plugin/public'; -import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; -import { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; -import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; -import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; - +import type { UrlForwardingStart } from '@kbn/url-forwarding-plugin/public'; +import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; +import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; import type { SpacesApi } from '@kbn/spaces-plugin/public'; -import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public'; @@ -59,10 +56,11 @@ import { memoize, noop } from 'lodash'; import type { NoDataPagePluginStart } from '@kbn/no-data-page-plugin/public'; import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; -import { DiscoverStartPlugins } from './plugin'; -import { DiscoverContextAppLocator } from './application/context/services/locator'; -import { DiscoverSingleDocLocator } from './application/doc/locator'; -import { DiscoverAppLocator } from '../common'; +import type { DiscoverStartPlugins } from './plugin'; +import type { DiscoverContextAppLocator } from './application/context/services/locator'; +import type { DiscoverSingleDocLocator } from './application/doc/locator'; +import type { DiscoverAppLocator } from '../common'; +import type { ProfilesManager } from './context_awareness'; /** * Location state of internal Discover history instance @@ -129,6 +127,7 @@ export interface DiscoverServices { contentClient: ContentClient; noDataPage?: NoDataPagePluginStart; observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; + profilesManager: ProfilesManager; } export const buildServices = memoize( @@ -142,6 +141,7 @@ export const buildServices = memoize( history, scopedHistory, urlTracker, + profilesManager, setHeaderActionMenu = noop, }: { core: CoreStart; @@ -153,6 +153,7 @@ export const buildServices = memoize( history: History; scopedHistory?: ScopedHistory; urlTracker: UrlTracker; + profilesManager: ProfilesManager; setHeaderActionMenu?: AppMountParameters['setHeaderActionMenu']; }): DiscoverServices => { const { usageCollection } = plugins; @@ -212,6 +213,7 @@ export const buildServices = memoize( contentClient: plugins.contentManagement.client, noDataPage: plugins.noDataPage, observabilityAIAssistant: plugins.observabilityAIAssistant, + profilesManager, }; } ); diff --git a/src/plugins/discover/public/components/discover_container/discover_container.tsx b/src/plugins/discover/public/components/discover_container/discover_container.tsx index 4f768554e1e5..c253760fff2a 100644 --- a/src/plugins/discover/public/components/discover_container/discover_container.tsx +++ b/src/plugins/discover/public/components/discover_container/discover_container.tsx @@ -44,6 +44,7 @@ const discoverContainerWrapperCss = css` `; const customizationContext: DiscoverCustomizationContext = { + solutionNavId: null, displayMode: 'embedded', inlineTopNav: { enabled: false, diff --git a/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx index 3907bc499923..cb02e3b73666 100644 --- a/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx +++ b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx @@ -25,6 +25,7 @@ import { ReactWrapper } from 'enzyme'; import { setUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/plugin'; import { mockUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/__mocks__'; import { FlyoutCustomization, useDiscoverCustomization } from '../../customizations'; +import { discoverServiceMock } from '../../__mocks__/services'; const mockFlyoutCustomization: FlyoutCustomization = { id: 'flyout', @@ -76,6 +77,7 @@ describe('Discover flyout', function () { }) => { const onClose = jest.fn(); const services = { + ...discoverServiceMock, filterManager: createFilterManagerMock(), addBasePath: (path: string) => `/base${path}`, history: () => ({ location: {} }), diff --git a/src/plugins/discover/public/context_awareness/__mocks__/index.ts b/src/plugins/discover/public/context_awareness/__mocks__/index.ts new file mode 100644 index 000000000000..0f8beed5d955 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/__mocks__/index.ts @@ -0,0 +1,100 @@ +/* + * 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 { getDataTableRecords } from '../../__fixtures__/real_hits'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; +import { + DataSourceCategory, + DataSourceProfileProvider, + DataSourceProfileService, + DocumentProfileProvider, + DocumentProfileService, + DocumentType, + RootProfileProvider, + RootProfileService, + SolutionType, +} from '../profiles'; +import { ProfilesManager } from '../profiles_manager'; + +export const createContextAwarenessMocks = () => { + const rootProfileProviderMock: RootProfileProvider = { + profileId: 'root-profile', + profile: { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + rootProfile: () => 'root-profile', + })), + }, + resolve: jest.fn(() => ({ + isMatch: true, + context: { + solutionType: SolutionType.Observability, + }, + })), + }; + + const dataSourceProfileProviderMock: DataSourceProfileProvider = { + profileId: 'data-source-profile', + profile: { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + rootProfile: () => 'data-source-profile', + })), + }, + resolve: jest.fn(() => ({ + isMatch: true, + context: { + category: DataSourceCategory.Logs, + }, + })), + }; + + const documentProfileProviderMock: DocumentProfileProvider = { + profileId: 'document-profile', + profile: { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + rootProfile: () => 'document-profile', + })), + } as DocumentProfileProvider['profile'], + resolve: jest.fn(() => ({ + isMatch: true, + context: { + type: DocumentType.Log, + }, + })), + }; + + const records = getDataTableRecords(dataViewWithTimefieldMock); + const contextRecordMock = records[0]; + const contextRecordMock2 = records[1]; + + const rootProfileServiceMock = new RootProfileService(); + rootProfileServiceMock.registerProvider(rootProfileProviderMock); + + const dataSourceProfileServiceMock = new DataSourceProfileService(); + dataSourceProfileServiceMock.registerProvider(dataSourceProfileProviderMock); + + const documentProfileServiceMock = new DocumentProfileService(); + documentProfileServiceMock.registerProvider(documentProfileProviderMock); + + const profilesManagerMock = new ProfilesManager( + rootProfileServiceMock, + dataSourceProfileServiceMock, + documentProfileServiceMock + ); + + return { + rootProfileProviderMock, + dataSourceProfileProviderMock, + documentProfileProviderMock, + contextRecordMock, + contextRecordMock2, + profilesManagerMock, + }; +}; diff --git a/src/plugins/discover/public/context_awareness/composable_profile.test.ts b/src/plugins/discover/public/context_awareness/composable_profile.test.ts new file mode 100644 index 000000000000..251da37fa012 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/composable_profile.test.ts @@ -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 { ComposableProfile, getMergedAccessor } from './composable_profile'; +import { Profile } from './types'; + +describe('getMergedAccessor', () => { + it('should return the base implementation if no profiles are provided', () => { + const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() })); + const mergedAccessor = getMergedAccessor([], 'getCellRenderers', baseImpl); + const result = mergedAccessor(); + expect(baseImpl).toHaveBeenCalled(); + expect(result).toEqual({ base: expect.any(Function) }); + }); + + it('should merge the accessors in the correct order', () => { + const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() })); + const profile1: ComposableProfile = { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + profile1: jest.fn(), + })), + }; + const profile2: ComposableProfile = { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + profile2: jest.fn(), + })), + }; + const mergedAccessor = getMergedAccessor([profile1, profile2], 'getCellRenderers', baseImpl); + const result = mergedAccessor(); + expect(baseImpl).toHaveBeenCalled(); + expect(profile1.getCellRenderers).toHaveBeenCalled(); + expect(profile2.getCellRenderers).toHaveBeenCalled(); + expect(result).toEqual({ + base: expect.any(Function), + profile1: expect.any(Function), + profile2: expect.any(Function), + }); + expect(Object.keys(result)).toEqual(['base', 'profile1', 'profile2']); + }); + + it('should allow overwriting previous accessors', () => { + const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() })); + const profile1: ComposableProfile = { + getCellRenderers: jest.fn(() => () => ({ profile1: jest.fn() })), + }; + const profile2: ComposableProfile = { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + profile2: jest.fn(), + })), + }; + const mergedAccessor = getMergedAccessor([profile1, profile2], 'getCellRenderers', baseImpl); + const result = mergedAccessor(); + expect(baseImpl).not.toHaveBeenCalled(); + expect(profile1.getCellRenderers).toHaveBeenCalled(); + expect(profile2.getCellRenderers).toHaveBeenCalled(); + expect(result).toEqual({ profile1: expect.any(Function), profile2: expect.any(Function) }); + expect(Object.keys(result)).toEqual(['profile1', 'profile2']); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/composable_profile.ts b/src/plugins/discover/public/context_awareness/composable_profile.ts new file mode 100644 index 000000000000..c2211dee3f37 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/composable_profile.ts @@ -0,0 +1,28 @@ +/* + * 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 { Profile } from './types'; + +export type PartialProfile = Partial; + +export type ComposableAccessor = (getPrevious: T) => T; + +export type ComposableProfile = { + [TKey in keyof TProfile]?: ComposableAccessor; +}; + +export const getMergedAccessor = ( + profiles: ComposableProfile[], + key: TKey, + baseImpl: Profile[TKey] +) => { + return profiles.reduce((nextAccessor, profile) => { + const currentAccessor = profile[key]; + return currentAccessor ? currentAccessor(nextAccessor) : nextAccessor; + }, baseImpl); +}; diff --git a/src/plugins/discover/public/context_awareness/hooks/index.ts b/src/plugins/discover/public/context_awareness/hooks/index.ts new file mode 100644 index 000000000000..3235844de4fc --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/index.ts @@ -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 { useProfileAccessor } from './use_profile_accessor'; +export { useRootProfile } from './use_root_profile'; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts new file mode 100644 index 000000000000..7f3cd816ae9e --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { ComposableProfile, getMergedAccessor } from '../composable_profile'; +import { useProfileAccessor } from './use_profile_accessor'; +import { getDataTableRecords } from '../../__fixtures__/real_hits'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; +import { useProfiles } from './use_profiles'; + +let mockProfiles: ComposableProfile[] = []; + +jest.mock('./use_profiles', () => ({ + useProfiles: jest.fn(() => mockProfiles), +})); + +jest.mock('../composable_profile', () => { + const originalModule = jest.requireActual('../composable_profile'); + return { + ...originalModule, + getMergedAccessor: jest.fn(originalModule.getMergedAccessor), + }; +}); + +const record = getDataTableRecords(dataViewWithTimefieldMock)[0]; + +describe('useProfileAccessor', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProfiles = [ + { getCellRenderers: (prev) => () => ({ ...prev(), profile1: jest.fn() }) }, + { getCellRenderers: (prev) => () => ({ ...prev(), profile2: jest.fn() }) }, + ]; + }); + + it('should return a function that merges accessors', () => { + const { result } = renderHook(() => useProfileAccessor('getCellRenderers', { record })); + expect(useProfiles).toHaveBeenCalledTimes(1); + expect(useProfiles).toHaveBeenCalledWith({ record }); + const base = () => ({ base: jest.fn() }); + const accessor = result.current(base); + expect(getMergedAccessor).toHaveBeenCalledTimes(1); + expect(getMergedAccessor).toHaveBeenCalledWith(mockProfiles, 'getCellRenderers', base); + const renderers = accessor(); + expect(renderers).toEqual({ + base: expect.any(Function), + profile1: expect.any(Function), + profile2: expect.any(Function), + }); + expect(Object.keys(renderers)).toEqual(['base', 'profile1', 'profile2']); + }); + + it('should recalculate the accessor when the key changes', () => { + const { rerender, result } = renderHook(({ key }) => useProfileAccessor(key, { record }), { + initialProps: { key: 'getCellRenderers' as const }, + }); + const prevResult = result.current; + rerender({ key: 'getCellRenderers' }); + expect(result.current).toBe(prevResult); + rerender({ key: 'otherKey' as unknown as 'getCellRenderers' }); + expect(result.current).not.toBe(prevResult); + }); + + it('should recalculate the accessor when the profiles change', () => { + const { rerender, result } = renderHook(() => + useProfileAccessor('getCellRenderers', { record }) + ); + const prevResult = result.current; + mockProfiles = [{ getCellRenderers: (prev) => () => ({ ...prev(), profile3: jest.fn() }) }]; + rerender(); + expect(result.current).not.toBe(prevResult); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.ts b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.ts new file mode 100644 index 000000000000..58c5a316f86c --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.ts @@ -0,0 +1,25 @@ +/* + * 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 { useCallback } from 'react'; +import { getMergedAccessor } from '../composable_profile'; +import type { GetProfilesOptions } from '../profiles_manager'; +import { useProfiles } from './use_profiles'; +import type { Profile } from '../types'; + +export const useProfileAccessor = ( + key: TKey, + options: GetProfilesOptions = {} +) => { + const profiles = useProfiles(options); + + return useCallback( + (baseImpl: Profile[TKey]) => getMergedAccessor(profiles, key, baseImpl), + [key, profiles] + ); +}; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx b/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx new file mode 100644 index 000000000000..f8613e4fea38 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx @@ -0,0 +1,91 @@ +/* + * 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 { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { discoverServiceMock } from '../../__mocks__/services'; +import { GetProfilesOptions } from '../profiles_manager'; +import { createContextAwarenessMocks } from '../__mocks__'; +import { useProfiles } from './use_profiles'; + +const { + rootProfileProviderMock, + dataSourceProfileProviderMock, + documentProfileProviderMock, + contextRecordMock, + contextRecordMock2, + profilesManagerMock, +} = createContextAwarenessMocks(); + +profilesManagerMock.resolveRootProfile({}); +profilesManagerMock.resolveDataSourceProfile({}); + +const record = profilesManagerMock.resolveDocumentProfile({ record: contextRecordMock }); +const record2 = profilesManagerMock.resolveDocumentProfile({ record: contextRecordMock2 }); + +discoverServiceMock.profilesManager = profilesManagerMock; + +const getProfilesSpy = jest.spyOn(discoverServiceMock.profilesManager, 'getProfiles'); +const getProfiles$Spy = jest.spyOn(discoverServiceMock.profilesManager, 'getProfiles$'); + +const render = () => { + return renderHook((props) => useProfiles(props), { + initialProps: { record } as GetProfilesOptions, + wrapper: ({ children }) => ( + {children} + ), + }); +}; + +describe('useProfiles', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return profiles', () => { + const { result } = render(); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + expect(result.current).toEqual([ + rootProfileProviderMock.profile, + dataSourceProfileProviderMock.profile, + documentProfileProviderMock.profile, + ]); + }); + + it('should return the same array reference if profiles do not change', () => { + const { result, rerender } = render(); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + const prevResult = result.current; + rerender({ record }); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + expect(result.current).toBe(prevResult); + rerender({ record: record2 }); + expect(getProfilesSpy).toHaveBeenCalledTimes(3); + expect(getProfiles$Spy).toHaveBeenCalledTimes(2); + expect(result.current).toBe(prevResult); + }); + + it('should return a different array reference if profiles change', () => { + const { result, rerender } = render(); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + const prevResult = result.current; + rerender({ record }); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + expect(result.current).toBe(prevResult); + rerender({ record: undefined }); + expect(getProfilesSpy).toHaveBeenCalledTimes(3); + expect(getProfiles$Spy).toHaveBeenCalledTimes(2); + expect(result.current).not.toBe(prevResult); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profiles.ts b/src/plugins/discover/public/context_awareness/hooks/use_profiles.ts new file mode 100644 index 000000000000..9bd86e438615 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_profiles.ts @@ -0,0 +1,36 @@ +/* + * 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 { useEffect, useMemo, useState } from 'react'; +import { useDiscoverServices } from '../../hooks/use_discover_services'; +import type { GetProfilesOptions } from '../profiles_manager'; + +export const useProfiles = ({ record }: GetProfilesOptions = {}) => { + const { profilesManager } = useDiscoverServices(); + const [profiles, setProfiles] = useState(() => profilesManager.getProfiles({ record })); + const profiles$ = useMemo( + () => profilesManager.getProfiles$({ record }), + [profilesManager, record] + ); + + useEffect(() => { + const subscription = profiles$.subscribe((newProfiles) => { + setProfiles((currentProfiles) => { + return currentProfiles.every((profile, i) => profile === newProfiles[i]) + ? currentProfiles + : newProfiles; + }); + }); + + return () => { + subscription.unsubscribe(); + }; + }, [profiles$]); + + return profiles; +}; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx new file mode 100644 index 000000000000..a41ec7c23cf8 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { discoverServiceMock } from '../../__mocks__/services'; +import { useRootProfile } from './use_root_profile'; + +const render = () => { + return renderHook((props) => useRootProfile(props), { + initialProps: { solutionNavId: 'solutionNavId' }, + wrapper: ({ children }) => ( + {children} + ), + }); +}; + +describe('useRootProfile', () => { + it('should return rootProfileLoading as true', () => { + const { result } = render(); + expect(result.current.rootProfileLoading).toBe(true); + }); + + it('should return rootProfileLoading as false', async () => { + const { result, waitForNextUpdate } = render(); + await waitForNextUpdate(); + expect(result.current.rootProfileLoading).toBe(false); + }); + + it('should return rootProfileLoading as true when solutionNavId changes', async () => { + const { result, rerender, waitForNextUpdate } = render(); + await waitForNextUpdate(); + expect(result.current.rootProfileLoading).toBe(false); + rerender({ solutionNavId: 'newSolutionNavId' }); + expect(result.current.rootProfileLoading).toBe(true); + await waitForNextUpdate(); + expect(result.current.rootProfileLoading).toBe(false); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts new file mode 100644 index 000000000000..ff2d7edbcefb --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts @@ -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 { useEffect, useState } from 'react'; +import { useDiscoverServices } from '../../hooks/use_discover_services'; + +export const useRootProfile = ({ solutionNavId }: { solutionNavId: string | null }) => { + const { profilesManager } = useDiscoverServices(); + const [rootProfileLoading, setRootProfileLoading] = useState(true); + + useEffect(() => { + let aborted = false; + + setRootProfileLoading(true); + + profilesManager.resolveRootProfile({ solutionNavId }).then(() => { + if (!aborted) { + setRootProfileLoading(false); + } + }); + + return () => { + aborted = true; + }; + }, [profilesManager, solutionNavId]); + + return { rootProfileLoading }; +}; diff --git a/src/plugins/discover/public/context_awareness/index.ts b/src/plugins/discover/public/context_awareness/index.ts new file mode 100644 index 000000000000..6106d9d154e4 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/index.ts @@ -0,0 +1,13 @@ +/* + * 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 * from './types'; +export * from './profiles'; +export { getMergedAccessor } from './composable_profile'; +export { ProfilesManager } from './profiles_manager'; +export { useProfileAccessor, useRootProfile } from './hooks'; diff --git a/src/plugins/discover/public/context_awareness/profile_service.test.ts b/src/plugins/discover/public/context_awareness/profile_service.test.ts new file mode 100644 index 000000000000..e306ee149f52 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profile_service.test.ts @@ -0,0 +1,147 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +import { AsyncProfileService, ContextWithProfileId, ProfileService } from './profile_service'; +import { Profile } from './types'; + +interface TestParams { + myParam: string; +} + +interface TestContext { + myContext: string; +} + +const defaultContext: ContextWithProfileId = { + profileId: 'test-profile', + myContext: 'test', +}; + +class TestProfileService extends ProfileService { + constructor() { + super(defaultContext); + } +} + +type TestProfileProvider = Parameters[0]; + +class TestAsyncProfileService extends AsyncProfileService { + constructor() { + super(defaultContext); + } +} + +type TestAsyncProfileProvider = Parameters[0]; + +const provider: TestProfileProvider = { + profileId: 'test-profile-1', + profile: { getCellRenderers: jest.fn() }, + resolve: jest.fn(() => ({ isMatch: false })), +}; + +const provider2: TestProfileProvider = { + profileId: 'test-profile-2', + profile: { getCellRenderers: jest.fn() }, + resolve: jest.fn(({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })), +}; + +const provider3: TestProfileProvider = { + profileId: 'test-profile-3', + profile: { getCellRenderers: jest.fn() }, + resolve: jest.fn(({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })), +}; + +const asyncProvider2: TestAsyncProfileProvider = { + profileId: 'test-profile-2', + profile: { getCellRenderers: jest.fn() }, + resolve: jest.fn(async ({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })), +}; + +describe('ProfileService', () => { + let service: TestProfileService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new TestProfileService(); + }); + + it('should expose defaultContext', () => { + expect(service.defaultContext).toBe(defaultContext); + }); + + it('should allow registering providers and getting profiles', () => { + service.registerProvider(provider); + service.registerProvider(provider2); + expect(service.getProfile({ profileId: 'test-profile-1', myContext: 'test' })).toBe( + provider.profile + ); + expect(service.getProfile({ profileId: 'test-profile-2', myContext: 'test' })).toBe( + provider2.profile + ); + }); + + it('should return empty profile if no provider is found', () => { + service.registerProvider(provider); + expect(service.getProfile({ profileId: 'test-profile-2', myContext: 'test' })).toEqual({}); + }); + + it('should resolve to first matching context', () => { + service.registerProvider(provider); + service.registerProvider(provider2); + service.registerProvider(provider3); + expect(service.resolve({ myParam: 'test' })).toEqual({ + profileId: 'test-profile-2', + myContext: 'test', + }); + expect(provider.resolve).toHaveBeenCalledTimes(1); + expect(provider.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + expect(provider2.resolve).toHaveBeenCalledTimes(1); + expect(provider2.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + expect(provider3.resolve).not.toHaveBeenCalled(); + }); + + it('should resolve to default context if no matching context is found', () => { + service.registerProvider(provider); + expect(service.resolve({ myParam: 'test' })).toEqual(defaultContext); + expect(provider.resolve).toHaveBeenCalledTimes(1); + expect(provider.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + }); +}); + +describe('AsyncProfileService', () => { + let service: TestAsyncProfileService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new TestAsyncProfileService(); + }); + + it('should resolve to first matching context', async () => { + service.registerProvider(provider); + service.registerProvider(asyncProvider2); + service.registerProvider(provider3); + await expect(service.resolve({ myParam: 'test' })).resolves.toEqual({ + profileId: 'test-profile-2', + myContext: 'test', + }); + expect(provider.resolve).toHaveBeenCalledTimes(1); + expect(provider.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + expect(asyncProvider2.resolve).toHaveBeenCalledTimes(1); + expect(asyncProvider2.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + expect(provider3.resolve).not.toHaveBeenCalled(); + }); + + it('should resolve to default context if no matching context is found', async () => { + service.registerProvider(provider); + await expect(service.resolve({ myParam: 'test' })).resolves.toEqual(defaultContext); + expect(provider.resolve).toHaveBeenCalledTimes(1); + expect(provider.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/profile_service.ts b/src/plugins/discover/public/context_awareness/profile_service.ts new file mode 100644 index 000000000000..2b43595761d1 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profile_service.ts @@ -0,0 +1,105 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +import type { ComposableProfile, PartialProfile } from './composable_profile'; +import type { Profile } from './types'; + +export type ResolveProfileResult = + | { isMatch: true; context: TContext } + | { isMatch: false }; + +export type ProfileProviderMode = 'sync' | 'async'; + +export interface ProfileProvider< + TProfile extends PartialProfile, + TParams, + TContext, + TMode extends ProfileProviderMode +> { + profileId: string; + profile: ComposableProfile; + resolve: ( + params: TParams + ) => TMode extends 'sync' + ? ResolveProfileResult + : ResolveProfileResult | Promise>; +} + +export type ContextWithProfileId = TContext & { profileId: string }; + +const EMPTY_PROFILE = {}; + +abstract class BaseProfileService< + TProfile extends PartialProfile, + TParams, + TContext, + TMode extends ProfileProviderMode +> { + protected readonly providers: Array> = []; + + protected constructor(public readonly defaultContext: ContextWithProfileId) {} + + public registerProvider(provider: ProfileProvider) { + this.providers.push(provider); + } + + public getProfile(context: ContextWithProfileId): ComposableProfile { + const provider = this.providers.find((current) => current.profileId === context.profileId); + return provider?.profile ?? EMPTY_PROFILE; + } + + public abstract resolve( + params: TParams + ): TMode extends 'sync' + ? ContextWithProfileId + : Promise>; +} + +export class ProfileService< + TProfile extends PartialProfile, + TParams, + TContext +> extends BaseProfileService { + public resolve(params: TParams) { + for (const provider of this.providers) { + const result = provider.resolve(params); + + if (result.isMatch) { + return { + ...result.context, + profileId: provider.profileId, + }; + } + } + + return this.defaultContext; + } +} + +export class AsyncProfileService< + TProfile extends PartialProfile, + TParams, + TContext +> extends BaseProfileService { + public async resolve(params: TParams) { + for (const provider of this.providers) { + const result = await provider.resolve(params); + + if (result.isMatch) { + return { + ...result.context, + profileId: provider.profileId, + }; + } + } + + return this.defaultContext; + } +} diff --git a/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts b/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts new file mode 100644 index 000000000000..f616fef91325 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts @@ -0,0 +1,45 @@ +/* + * 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, Query } from '@kbn/es-query'; +import type { DiscoverDataSource } from '../../../common/data_sources'; +import { AsyncProfileService } from '../profile_service'; +import { Profile } from '../types'; + +export enum DataSourceCategory { + Logs = 'logs', + Default = 'default', +} + +export interface DataSourceProfileProviderParams { + dataSource?: DiscoverDataSource; + dataView?: DataView; + query?: Query | AggregateQuery; +} + +export interface DataSourceContext { + category: DataSourceCategory; +} + +export type DataSourceProfile = Profile; + +export class DataSourceProfileService extends AsyncProfileService< + DataSourceProfile, + DataSourceProfileProviderParams, + DataSourceContext +> { + constructor() { + super({ + profileId: 'default-data-source-profile', + category: DataSourceCategory.Default, + }); + } +} + +export type DataSourceProfileProvider = Parameters[0]; diff --git a/src/plugins/discover/public/context_awareness/profiles/document_profile.ts b/src/plugins/discover/public/context_awareness/profiles/document_profile.ts new file mode 100644 index 000000000000..70b134da452e --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/document_profile.ts @@ -0,0 +1,41 @@ +/* + * 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 { DataTableRecord } from '@kbn/discover-utils'; +import type { Profile } from '../types'; +import { ProfileService } from '../profile_service'; + +export enum DocumentType { + Log = 'log', + Default = 'default', +} + +export interface DocumentProfileProviderParams { + record: DataTableRecord; +} + +export interface DocumentContext { + type: DocumentType; +} + +export type DocumentProfile = Omit; + +export class DocumentProfileService extends ProfileService< + DocumentProfile, + DocumentProfileProviderParams, + DocumentContext +> { + constructor() { + super({ + profileId: 'default-document-profile', + type: DocumentType.Default, + }); + } +} + +export type DocumentProfileProvider = Parameters[0]; diff --git a/src/plugins/discover/public/context_awareness/profiles/example_profiles.tsx b/src/plugins/discover/public/context_awareness/profiles/example_profiles.tsx new file mode 100644 index 000000000000..3835337b2530 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/example_profiles.tsx @@ -0,0 +1,123 @@ +/* + * 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 { EuiBadge } from '@elastic/eui'; +import { + DataTableRecord, + getMessageFieldWithFallbacks, + LogDocumentOverview, +} from '@kbn/discover-utils'; +import { isOfAggregateQueryType } from '@kbn/es-query'; +import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { capitalize } from 'lodash'; +import React from 'react'; +import { DataSourceType, isDataSourceType } from '../../../common/data_sources'; +import { DataSourceCategory, DataSourceProfileProvider } from './data_source_profile'; +import { DocumentProfileProvider, DocumentType } from './document_profile'; +import { RootProfileProvider, SolutionType } from './root_profile'; + +export const o11yRootProfileProvider: RootProfileProvider = { + profileId: 'o11y-root-profile', + profile: {}, + resolve: (params) => { + if (params.solutionNavId === 'oblt') { + return { + isMatch: true, + context: { + solutionType: SolutionType.Observability, + }, + }; + } + + return { isMatch: false }; + }, +}; + +export const logsDataSourceProfileProvider: DataSourceProfileProvider = { + profileId: 'logs-data-source-profile', + profile: { + getCellRenderers: (prev) => () => ({ + ...prev(), + '@timestamp': (props) => { + const timestamp = getFieldValue(props.row, '@timestamp'); + return ( + + {timestamp} + + ); + }, + 'log.level': (props) => { + const level = getFieldValue(props.row, 'log.level'); + if (!level) { + return (None); + } + const levelMap: Record = { + info: 'primary', + debug: 'default', + error: 'danger', + }; + return ( + + {capitalize(level)} + + ); + }, + message: (props) => { + const { value } = getMessageFieldWithFallbacks( + props.row.flattened as unknown as LogDocumentOverview + ); + return value || (None); + }, + }), + }, + resolve: (params) => { + let indices: string[] = []; + + if (isDataSourceType(params.dataSource, DataSourceType.Esql)) { + if (!isOfAggregateQueryType(params.query)) { + return { isMatch: false }; + } + + indices = getIndexPatternFromESQLQuery(params.query.esql).split(','); + } else if (isDataSourceType(params.dataSource, DataSourceType.DataView) && params.dataView) { + indices = params.dataView.getIndexPattern().split(','); + } + + if (indices.every((index) => index.startsWith('logs-'))) { + return { + isMatch: true, + context: { category: DataSourceCategory.Logs }, + }; + } + + return { isMatch: false }; + }, +}; + +export const logDocumentProfileProvider: DocumentProfileProvider = { + profileId: 'log-document-profile', + profile: {}, + resolve: (params) => { + if (getFieldValue(params.record, 'data_stream.type') === 'logs') { + return { + isMatch: true, + context: { + type: DocumentType.Log, + }, + }; + } + + return { isMatch: false }; + }, +}; + +const getFieldValue = (record: DataTableRecord, field: string) => { + const value = record.flattened[field]; + return Array.isArray(value) ? value[0] : value; +}; diff --git a/src/plugins/discover/public/context_awareness/profiles/index.ts b/src/plugins/discover/public/context_awareness/profiles/index.ts new file mode 100644 index 000000000000..f661276b4a04 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/index.ts @@ -0,0 +1,11 @@ +/* + * 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 * from './root_profile'; +export * from './data_source_profile'; +export * from './document_profile'; diff --git a/src/plugins/discover/public/context_awareness/profiles/root_profile.ts b/src/plugins/discover/public/context_awareness/profiles/root_profile.ts new file mode 100644 index 000000000000..42497fe680c5 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/root_profile.ts @@ -0,0 +1,42 @@ +/* + * 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 { Profile } from '../types'; +import { AsyncProfileService } from '../profile_service'; + +export enum SolutionType { + Observability = 'oblt', + Security = 'security', + Search = 'search', + Default = 'default', +} + +export interface RootProfileProviderParams { + solutionNavId?: string | null; +} + +export interface RootContext { + solutionType: SolutionType; +} + +export type RootProfile = Profile; + +export class RootProfileService extends AsyncProfileService< + RootProfile, + RootProfileProviderParams, + RootContext +> { + constructor() { + super({ + profileId: 'default-root-profile', + solutionType: SolutionType.Default, + }); + } +} + +export type RootProfileProvider = Parameters[0]; diff --git a/src/plugins/discover/public/context_awareness/profiles_manager.test.ts b/src/plugins/discover/public/context_awareness/profiles_manager.test.ts new file mode 100644 index 000000000000..153ef979aabb --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles_manager.test.ts @@ -0,0 +1,263 @@ +/* + * 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 { firstValueFrom, Subject } from 'rxjs'; +import { createEsqlDataSource } from '../../common/data_sources'; +import { addLog } from '../utils/add_log'; +import { createContextAwarenessMocks } from './__mocks__'; + +jest.mock('../utils/add_log'); + +let mocks = createContextAwarenessMocks(); + +describe('ProfilesManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + mocks = createContextAwarenessMocks(); + }); + + it('should return default profiles', () => { + const profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, {}, {}]); + }); + + it('should resolve root profile', async () => { + await mocks.profilesManagerMock.resolveRootProfile({}); + const profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([mocks.rootProfileProviderMock.profile, {}, {}]); + }); + + it('should resolve data source profile', async () => { + await mocks.profilesManagerMock.resolveDataSourceProfile({}); + const profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, mocks.dataSourceProfileProviderMock.profile, {}]); + }); + + it('should resolve document profile', async () => { + const record = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock, + }); + const profiles = mocks.profilesManagerMock.getProfiles({ record }); + expect(profiles).toEqual([{}, {}, mocks.documentProfileProviderMock.profile]); + }); + + it('should resolve multiple profiles', async () => { + await mocks.profilesManagerMock.resolveRootProfile({}); + await mocks.profilesManagerMock.resolveDataSourceProfile({}); + const record = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock, + }); + const profiles = mocks.profilesManagerMock.getProfiles({ record }); + expect(profiles).toEqual([ + mocks.rootProfileProviderMock.profile, + mocks.dataSourceProfileProviderMock.profile, + mocks.documentProfileProviderMock.profile, + ]); + }); + + it('should expose profiles as an observable', async () => { + const getProfilesSpy = jest.spyOn(mocks.profilesManagerMock, 'getProfiles'); + const record = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock, + }); + const profiles$ = mocks.profilesManagerMock.getProfiles$({ record }); + const next = jest.fn(); + profiles$.subscribe(next); + expect(getProfilesSpy).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith([{}, {}, mocks.documentProfileProviderMock.profile]); + await mocks.profilesManagerMock.resolveRootProfile({}); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(next).toHaveBeenCalledWith([ + mocks.rootProfileProviderMock.profile, + {}, + mocks.documentProfileProviderMock.profile, + ]); + await mocks.profilesManagerMock.resolveDataSourceProfile({}); + expect(getProfilesSpy).toHaveBeenCalledTimes(3); + expect(next).toHaveBeenCalledWith([ + mocks.rootProfileProviderMock.profile, + mocks.dataSourceProfileProviderMock.profile, + mocks.documentProfileProviderMock.profile, + ]); + }); + + it("should not resolve root profile again if params haven't changed", async () => { + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' }); + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' }); + expect(mocks.rootProfileProviderMock.resolve).toHaveBeenCalledTimes(1); + }); + + it('should resolve root profile again if params have changed', async () => { + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' }); + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'newSolutionNavId' }); + expect(mocks.rootProfileProviderMock.resolve).toHaveBeenCalledTimes(2); + }); + + it('should not resolve data source profile again if params have not changed', async () => { + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + expect(mocks.dataSourceProfileProviderMock.resolve).toHaveBeenCalledTimes(1); + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + expect(mocks.dataSourceProfileProviderMock.resolve).toHaveBeenCalledTimes(1); + }); + + it('should resolve data source profile again if params have changed', async () => { + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + expect(mocks.dataSourceProfileProviderMock.resolve).toHaveBeenCalledTimes(1); + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from logs-*' }, + }); + expect(mocks.dataSourceProfileProviderMock.resolve).toHaveBeenCalledTimes(2); + }); + + it('should log an error and fall back to the default profile if root profile resolution fails', async () => { + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' }); + let profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([mocks.rootProfileProviderMock.profile, {}, {}]); + const resolveSpy = jest.spyOn(mocks.rootProfileProviderMock, 'resolve'); + resolveSpy.mockRejectedValue(new Error('Failed to resolve')); + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'newSolutionNavId' }); + expect(addLog).toHaveBeenCalledWith( + '[ProfilesManager] root context resolution failed with params: {\n "solutionNavId": "newSolutionNavId"\n}', + new Error('Failed to resolve') + ); + profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, {}, {}]); + }); + + it('should log an error and fall back to the default profile if data source profile resolution fails', async () => { + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + let profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, mocks.dataSourceProfileProviderMock.profile, {}]); + const resolveSpy = jest.spyOn(mocks.dataSourceProfileProviderMock, 'resolve'); + resolveSpy.mockRejectedValue(new Error('Failed to resolve')); + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from logs-*' }, + }); + expect(addLog).toHaveBeenCalledWith( + '[ProfilesManager] data source context resolution failed with params: {\n "esqlQuery": "from logs-*"\n}', + new Error('Failed to resolve') + ); + profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, {}, {}]); + }); + + it('should log an error and fall back to the default profile if document profile resolution fails', () => { + const record = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock, + }); + let profiles = mocks.profilesManagerMock.getProfiles({ record }); + expect(profiles).toEqual([{}, {}, mocks.documentProfileProviderMock.profile]); + const resolveSpy = jest.spyOn(mocks.documentProfileProviderMock, 'resolve'); + resolveSpy.mockImplementation(() => { + throw new Error('Failed to resolve'); + }); + const record2 = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock2, + }); + profiles = mocks.profilesManagerMock.getProfiles({ record: record2 }); + expect(addLog).toHaveBeenCalledWith( + '[ProfilesManager] document context resolution failed with params: {\n "recordId": "logstash-2014.09.09::388::"\n}', + new Error('Failed to resolve') + ); + expect(profiles).toEqual([{}, {}, {}]); + }); + + it('should cancel existing root profile resolution when another is triggered', async () => { + const context = await mocks.rootProfileProviderMock.resolve({ solutionNavId: 'solutionNavId' }); + const newContext = await mocks.rootProfileProviderMock.resolve({ + solutionNavId: 'newSolutionNavId', + }); + const resolveSpy = jest.spyOn(mocks.rootProfileProviderMock, 'resolve'); + resolveSpy.mockClear(); + const resolvedDeferredResult$ = new Subject(); + const deferredResult = firstValueFrom(resolvedDeferredResult$).then(() => context); + resolveSpy.mockResolvedValueOnce(deferredResult); + const promise1 = mocks.profilesManagerMock.resolveRootProfile({ + solutionNavId: 'solutionNavId', + }); + expect(resolveSpy).toHaveReturnedTimes(1); + expect(resolveSpy).toHaveLastReturnedWith(deferredResult); + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + const resolvedDeferredResult2$ = new Subject(); + const deferredResult2 = firstValueFrom(resolvedDeferredResult2$).then(() => newContext); + resolveSpy.mockResolvedValueOnce(deferredResult2); + const promise2 = mocks.profilesManagerMock.resolveRootProfile({ + solutionNavId: 'newSolutionNavId', + }); + expect(resolveSpy).toHaveReturnedTimes(2); + expect(resolveSpy).toHaveLastReturnedWith(deferredResult2); + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + resolvedDeferredResult$.next(undefined); + await promise1; + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + resolvedDeferredResult2$.next(undefined); + await promise2; + expect(mocks.profilesManagerMock.getProfiles()).toEqual([ + mocks.rootProfileProviderMock.profile, + {}, + {}, + ]); + }); + + it('should cancel existing data source profile resolution when another is triggered', async () => { + const context = await mocks.dataSourceProfileProviderMock.resolve({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + const newContext = await mocks.dataSourceProfileProviderMock.resolve({ + dataSource: createEsqlDataSource(), + query: { esql: 'from logs-*' }, + }); + const resolveSpy = jest.spyOn(mocks.dataSourceProfileProviderMock, 'resolve'); + resolveSpy.mockClear(); + const resolvedDeferredResult$ = new Subject(); + const deferredResult = firstValueFrom(resolvedDeferredResult$).then(() => context); + resolveSpy.mockResolvedValueOnce(deferredResult); + const promise1 = mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + expect(resolveSpy).toHaveReturnedTimes(1); + expect(resolveSpy).toHaveLastReturnedWith(deferredResult); + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + const resolvedDeferredResult2$ = new Subject(); + const deferredResult2 = firstValueFrom(resolvedDeferredResult2$).then(() => newContext); + resolveSpy.mockResolvedValueOnce(deferredResult2); + const promise2 = mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from logs-*' }, + }); + expect(resolveSpy).toHaveReturnedTimes(2); + expect(resolveSpy).toHaveLastReturnedWith(deferredResult2); + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + resolvedDeferredResult$.next(undefined); + await promise1; + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + resolvedDeferredResult2$.next(undefined); + await promise2; + expect(mocks.profilesManagerMock.getProfiles()).toEqual([ + {}, + mocks.dataSourceProfileProviderMock.profile, + {}, + ]); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/profiles_manager.ts b/src/plugins/discover/public/context_awareness/profiles_manager.ts new file mode 100644 index 000000000000..316419d2a7d3 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles_manager.ts @@ -0,0 +1,206 @@ +/* + * 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 { DataTableRecord } from '@kbn/discover-utils'; +import { isOfAggregateQueryType } from '@kbn/es-query'; +import { isEqual } from 'lodash'; +import { BehaviorSubject, combineLatest, map } from 'rxjs'; +import { DataSourceType, isDataSourceType } from '../../common/data_sources'; +import { addLog } from '../utils/add_log'; +import type { + RootProfileService, + DataSourceProfileService, + DocumentProfileService, + RootProfileProviderParams, + DataSourceProfileProviderParams, + DocumentProfileProviderParams, + RootContext, + DataSourceContext, + DocumentContext, +} from './profiles'; +import type { ContextWithProfileId } from './profile_service'; + +interface SerializedRootProfileParams { + solutionNavId: RootProfileProviderParams['solutionNavId']; +} + +interface SerializedDataSourceProfileParams { + dataViewId: string | undefined; + esqlQuery: string | undefined; +} + +interface DataTableRecordWithContext extends DataTableRecord { + context: ContextWithProfileId; +} + +export interface GetProfilesOptions { + record?: DataTableRecord; +} + +export class ProfilesManager { + private readonly rootContext$: BehaviorSubject>; + private readonly dataSourceContext$: BehaviorSubject>; + + private prevRootProfileParams?: SerializedRootProfileParams; + private prevDataSourceProfileParams?: SerializedDataSourceProfileParams; + private rootProfileAbortController?: AbortController; + private dataSourceProfileAbortController?: AbortController; + + constructor( + private readonly rootProfileService: RootProfileService, + private readonly dataSourceProfileService: DataSourceProfileService, + private readonly documentProfileService: DocumentProfileService + ) { + this.rootContext$ = new BehaviorSubject(rootProfileService.defaultContext); + this.dataSourceContext$ = new BehaviorSubject(dataSourceProfileService.defaultContext); + } + + public async resolveRootProfile(params: RootProfileProviderParams) { + const serializedParams = serializeRootProfileParams(params); + + if (isEqual(this.prevRootProfileParams, serializedParams)) { + return; + } + + const abortController = new AbortController(); + this.rootProfileAbortController?.abort(); + this.rootProfileAbortController = abortController; + + let context = this.rootProfileService.defaultContext; + + try { + context = await this.rootProfileService.resolve(params); + } catch (e) { + logResolutionError(ContextType.Root, serializedParams, e); + } + + if (abortController.signal.aborted) { + return; + } + + this.rootContext$.next(context); + this.prevRootProfileParams = serializedParams; + } + + public async resolveDataSourceProfile(params: DataSourceProfileProviderParams) { + const serializedParams = serializeDataSourceProfileParams(params); + + if (isEqual(this.prevDataSourceProfileParams, serializedParams)) { + return; + } + + const abortController = new AbortController(); + this.dataSourceProfileAbortController?.abort(); + this.dataSourceProfileAbortController = abortController; + + let context = this.dataSourceProfileService.defaultContext; + + try { + context = await this.dataSourceProfileService.resolve(params); + } catch (e) { + logResolutionError(ContextType.DataSource, serializedParams, e); + } + + if (abortController.signal.aborted) { + return; + } + + this.dataSourceContext$.next(context); + this.prevDataSourceProfileParams = serializedParams; + } + + public resolveDocumentProfile(params: DocumentProfileProviderParams) { + let context: ContextWithProfileId | undefined; + + return new Proxy(params.record, { + has: (target, prop) => prop === 'context' || Reflect.has(target, prop), + get: (target, prop, receiver) => { + if (prop !== 'context') { + return Reflect.get(target, prop, receiver); + } + + if (!context) { + try { + context = this.documentProfileService.resolve(params); + } catch (e) { + logResolutionError(ContextType.Document, { recordId: params.record.id }, e); + context = this.documentProfileService.defaultContext; + } + } + + return context; + }, + }); + } + + public getProfiles({ record }: GetProfilesOptions = {}) { + return [ + this.rootProfileService.getProfile(this.rootContext$.getValue()), + this.dataSourceProfileService.getProfile(this.dataSourceContext$.getValue()), + this.documentProfileService.getProfile( + recordHasContext(record) ? record.context : this.documentProfileService.defaultContext + ), + ]; + } + + public getProfiles$(options: GetProfilesOptions = {}) { + return combineLatest([this.rootContext$, this.dataSourceContext$]).pipe( + map(() => this.getProfiles(options)) + ); + } +} + +const serializeRootProfileParams = ( + params: RootProfileProviderParams +): SerializedRootProfileParams => { + return { + solutionNavId: params.solutionNavId, + }; +}; + +const serializeDataSourceProfileParams = ( + params: DataSourceProfileProviderParams +): SerializedDataSourceProfileParams => { + return { + dataViewId: isDataSourceType(params.dataSource, DataSourceType.DataView) + ? params.dataSource.dataViewId + : undefined, + esqlQuery: + isDataSourceType(params.dataSource, DataSourceType.Esql) && + isOfAggregateQueryType(params.query) + ? params.query.esql + : undefined, + }; +}; + +const recordHasContext = ( + record: DataTableRecord | undefined +): record is DataTableRecordWithContext => { + return Boolean(record && 'context' in record); +}; + +enum ContextType { + Root = 'root', + DataSource = 'data source', + Document = 'document', +} + +const logResolutionError = ( + profileType: ContextType, + params: TParams, + error: TError +) => { + addLog( + `[ProfilesManager] ${profileType} context resolution failed with params: ${JSON.stringify( + params, + null, + 2 + )}`, + error + ); +}; diff --git a/src/plugins/discover/public/context_awareness/types.ts b/src/plugins/discover/public/context_awareness/types.ts new file mode 100644 index 000000000000..b612b2ce2990 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/types.ts @@ -0,0 +1,13 @@ +/* + * 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 { CustomCellRenderer } from '@kbn/unified-data-table'; + +export interface Profile { + getCellRenderers: () => CustomCellRenderer; +} diff --git a/src/plugins/discover/public/customizations/__mocks__/customization_context.ts b/src/plugins/discover/public/customizations/__mocks__/customization_context.ts index 6ede54673cda..1fabe661dd20 100644 --- a/src/plugins/discover/public/customizations/__mocks__/customization_context.ts +++ b/src/plugins/discover/public/customizations/__mocks__/customization_context.ts @@ -6,12 +6,6 @@ * Side Public License, v 1. */ -import type { DiscoverCustomizationContext } from '../types'; +import { defaultCustomizationContext } from '../defaults'; -export const mockCustomizationContext: DiscoverCustomizationContext = { - displayMode: 'standalone', - inlineTopNav: { - enabled: false, - showLogsExplorerTabs: false, - }, -}; +export const mockCustomizationContext = defaultCustomizationContext; diff --git a/src/plugins/discover/public/customizations/defaults.ts b/src/plugins/discover/public/customizations/defaults.ts index a9dc60ac356f..034e7be2b5dc 100644 --- a/src/plugins/discover/public/customizations/defaults.ts +++ b/src/plugins/discover/public/customizations/defaults.ts @@ -9,6 +9,7 @@ import { DiscoverCustomizationContext } from './types'; export const defaultCustomizationContext: DiscoverCustomizationContext = { + solutionNavId: null, displayMode: 'standalone', inlineTopNav: { enabled: false, diff --git a/src/plugins/discover/public/customizations/types.ts b/src/plugins/discover/public/customizations/types.ts index 079cde37da71..21419da70994 100644 --- a/src/plugins/discover/public/customizations/types.ts +++ b/src/plugins/discover/public/customizations/types.ts @@ -21,6 +21,10 @@ export type CustomizationCallback = ( export type DiscoverDisplayMode = 'embedded' | 'standalone'; export interface DiscoverCustomizationContext { + /** + * The current solution nav ID + */ + solutionNavId: string | null; /* * Display mode in which discover is running */ diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts index 35e336df5232..53ce8c798f25 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts @@ -9,6 +9,7 @@ import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; import type { DataView } from '@kbn/data-views-plugin/common'; +import { createDataViewDataSource } from '../../common/data_sources'; import { SHOW_FIELD_STATISTICS } from '@kbn/discover-utils'; import { buildDataViewMock, deepMockedFields } from '@kbn/discover-utils/src/__mocks__'; import { ViewMode } from '@kbn/embeddable-plugin/public'; @@ -17,7 +18,7 @@ import { ReactWrapper } from 'enzyme'; import { ReactElement } from 'react'; import { render } from 'react-dom'; import { act } from 'react-dom/test-utils'; -import { Observable, throwError } from 'rxjs'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { SearchInput } from '..'; import { VIEW_MODE } from '../../common/constants'; import { DiscoverServices } from '../build_services'; @@ -26,6 +27,7 @@ import { discoverServiceMock } from '../__mocks__/services'; import { getDiscoverLocatorParams } from './get_discover_locator_params'; import { SavedSearchEmbeddable, SearchEmbeddableConfig } from './saved_search_embeddable'; import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component'; +import { DiscoverGrid } from '../components/discover_grid'; jest.mock('./get_discover_locator_params', () => { const actual = jest.requireActual('./get_discover_locator_params'); @@ -140,6 +142,7 @@ describe('saved search embeddable', () => { }; beforeEach(() => { + jest.clearAllMocks(); mountpoint = document.createElement('div'); showFieldStatisticsMockValue = false; @@ -152,6 +155,10 @@ describe('saved search embeddable', () => { if (key === SHOW_FIELD_STATISTICS) return showFieldStatisticsMockValue; } ); + + jest + .spyOn(servicesMock.core.chrome, 'getActiveSolutionNavId$') + .mockReturnValue(new BehaviorSubject('test')); }); afterEach(() => { @@ -475,4 +482,56 @@ describe('saved search embeddable', () => { expect(editUrl).toBe('/base/mock-url'); }); }); + + describe('context awareness', () => { + it('should resolve root profile on init', async () => { + const resolveRootProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveRootProfile' + ); + const { embeddable } = createEmbeddable(); + expect(resolveRootProfileSpy).not.toHaveBeenCalled(); + await waitOneTick(); + expect(resolveRootProfileSpy).toHaveBeenCalledWith({ solutionNavId: 'test' }); + resolveRootProfileSpy.mockReset(); + expect(resolveRootProfileSpy).not.toHaveBeenCalled(); + embeddable.reload(); + await waitOneTick(); + expect(resolveRootProfileSpy).not.toHaveBeenCalled(); + }); + + it('should resolve data source profile when fetching', async () => { + const resolveDataSourceProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDataSourceProfile' + ); + const { embeddable } = createEmbeddable(); + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); + await waitOneTick(); + expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({ + dataSource: createDataViewDataSource({ dataViewId: dataViewMock.id! }), + dataView: dataViewMock, + query: embeddable.getInput().query, + }); + resolveDataSourceProfileSpy.mockReset(); + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); + embeddable.reload(); + expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({ + dataSource: createDataViewDataSource({ dataViewId: dataViewMock.id! }), + dataView: dataViewMock, + query: embeddable.getInput().query, + }); + }); + + it('should pass cell renderers from profile', async () => { + const { embeddable } = createEmbeddable(); + await waitOneTick(); + embeddable.render(mountpoint); + const discoverGridComponent = discoverComponent.find(DiscoverGrid); + expect(discoverGridComponent.exists()).toBeTruthy(); + expect(Object.keys(discoverGridComponent.prop('externalCustomRenderers')!)).toEqual([ + 'rootProfile', + ]); + }); + }); }); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index 3a6f9f9c9c8a..861a0d50eeba 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { lastValueFrom, Subscription } from 'rxjs'; +import { firstValueFrom, lastValueFrom, Subscription } from 'rxjs'; import { onlyDisabledFiltersChanged, Filter, @@ -71,6 +71,7 @@ import { fetchEsql } from '../application/main/data_fetching/fetch_esql'; import { getValidViewMode } from '../application/main/utils/get_valid_view_mode'; import { ADHOC_DATA_VIEW_RENDER_EVENT } from '../constants'; import { getDiscoverLocatorParams } from './get_discover_locator_params'; +import { createDataViewDataSource, createEsqlDataSource } from '../../common/data_sources'; export interface SearchEmbeddableConfig { editable: boolean; @@ -163,6 +164,12 @@ export class SavedSearchEmbeddable await this.initializeOutput(); + const solutionNavId = await firstValueFrom( + this.services.core.chrome.getActiveSolutionNavId$() + ); + + await this.services.profilesManager.resolveRootProfile({ solutionNavId }); + // deferred loading of this embeddable is complete this.setInitializationFinished(); @@ -305,18 +312,29 @@ export class SavedSearchEmbeddable const isEsqlMode = this.isEsqlMode(savedSearch); try { + await this.services.profilesManager.resolveDataSourceProfile({ + dataSource: isOfAggregateQueryType(query) + ? createEsqlDataSource() + : dataView.id + ? createDataViewDataSource({ dataViewId: dataView.id }) + : undefined, + dataView, + query, + }); + // Request ES|QL data if (isEsqlMode && query) { - const result = await fetchEsql( - savedSearch.searchSource.getField('query')!, + const result = await fetchEsql({ + query: savedSearch.searchSource.getField('query')!, + inputQuery: this.input.query, + filters: this.input.filters, dataView, - this.services.data, - this.services.expressions, - this.services.inspector, - this.abortController.signal, - this.input.filters, - this.input.query - ); + abortSignal: this.abortController.signal, + inspectorAdapters: this.services.inspector, + data: this.services.data, + expressions: this.services.expressions, + profilesManager: this.services.profilesManager, + }); this.updateOutput({ ...this.getOutput(), diff --git a/src/plugins/discover/public/embeddable/saved_search_grid.tsx b/src/plugins/discover/public/embeddable/saved_search_grid.tsx index 8bf43fa5b3e3..39a6dc1307c0 100644 --- a/src/plugins/discover/public/embeddable/saved_search_grid.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_grid.tsx @@ -21,6 +21,7 @@ import './saved_search_grid.scss'; import { DiscoverGridFlyout } from '../components/discover_grid_flyout'; import { SavedSearchEmbeddableBase } from './saved_search_embeddable_base'; import { TotalDocuments } from '../application/main/components/total_documents/total_documents'; +import { useProfileAccessor } from '../context_awareness'; export interface DiscoverGridEmbeddableProps extends Omit { @@ -88,6 +89,12 @@ export function DiscoverGridEmbeddable(props: DiscoverGridEmbeddableProps) { [props.totalHitCount] ); + const getCellRenderersAccessor = useProfileAccessor('getCellRenderers'); + const cellRenderers = useMemo(() => { + const getCellRenderers = getCellRenderersAccessor(() => ({})); + return getCellRenderers(); + }, [getCellRenderersAccessor]); + return ( { it('is compatible when embeddable is of type saved search, in view mode && appropriate permissions are set', async () => { const action = new ViewSavedSearchAction(applicationMock, services.locator); diff --git a/src/plugins/discover/public/hooks/show_confirm_panel.tsx b/src/plugins/discover/public/hooks/show_confirm_panel.tsx deleted file mode 100644 index 79d2524c9316..000000000000 --- a/src/plugins/discover/public/hooks/show_confirm_panel.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 from 'react'; -import ReactDOM from 'react-dom'; -import { EuiConfirmModal } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; -import { StartRenderServices } from '../plugin'; - -let isOpenConfirmPanel = false; - -export const showConfirmPanel = ({ - onConfirm, - onCancel, - startServices, -}: { - onConfirm: () => void; - onCancel: () => void; - startServices: StartRenderServices; -}) => { - if (isOpenConfirmPanel) { - return; - } - - isOpenConfirmPanel = true; - const container = document.createElement('div'); - const onClose = () => { - ReactDOM.unmountComponentAtNode(container); - document.body.removeChild(container); - isOpenConfirmPanel = false; - }; - - document.body.appendChild(container); - const element = ( - - { - onClose(); - onCancel(); - }} - onConfirm={() => { - onClose(); - onConfirm(); - }} - cancelButtonText={i18n.translate('discover.confirmDataViewSave.cancel', { - defaultMessage: 'Cancel', - })} - confirmButtonText={i18n.translate('discover.confirmDataViewSave.saveAndContinue', { - defaultMessage: 'Save and continue', - })} - defaultFocusedButton="confirm" - > -

- {i18n.translate('discover.confirmDataViewSave.message', { - defaultMessage: 'The action you chose requires a saved data view.', - })} -

-
-
- ); - ReactDOM.render(element, container); -}; diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 2eb34b20345e..7228070fe2d2 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -82,6 +82,12 @@ import { import { getESQLSearchProvider } from './global_search/search_provider'; import { HistoryService } from './history_service'; import { ConfigSchema, ExperimentalFeatures } from '../common/config'; +import { + DataSourceProfileService, + DocumentProfileService, + ProfilesManager, + RootProfileService, +} from './context_awareness'; /** * @public @@ -209,8 +215,6 @@ export interface DiscoverStartPlugins { observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; } -export type StartRenderServices = Pick; - /** * Contains Discover, one of the oldest parts of Kibana * Discover provides embeddables for Dashboards @@ -218,25 +222,28 @@ export type StartRenderServices = Pick { - constructor(private readonly initializerContext: PluginInitializerContext) { - this.experimentalFeatures = - initializerContext.config.get().experimental ?? this.experimentalFeatures; - } + private readonly rootProfileService = new RootProfileService(); + private readonly dataSourceProfileService = new DataSourceProfileService(); + private readonly documentProfileService = new DocumentProfileService(); + private readonly appStateUpdater = new BehaviorSubject(() => ({})); + private readonly historyService = new HistoryService(); + private readonly inlineTopNav: Map = + new Map([[null, defaultCustomizationContext.inlineTopNav]]); + private readonly experimentalFeatures: ExperimentalFeatures = { + ruleFormV2Enabled: false, + }; - private appStateUpdater = new BehaviorSubject(() => ({})); - private historyService = new HistoryService(); private scopedHistory?: ScopedHistory; private urlTracker?: UrlTracker; - private stopUrlTracking: (() => void) | undefined = undefined; + private stopUrlTracking?: () => void; private locator?: DiscoverAppLocator; private contextLocator?: DiscoverContextAppLocator; private singleDocLocator?: DiscoverSingleDocLocator; - private inlineTopNav: Map = new Map([ - [null, defaultCustomizationContext.inlineTopNav], - ]); - private experimentalFeatures: ExperimentalFeatures = { - ruleFormV2Enabled: false, - }; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.experimentalFeatures = + initializerContext.config.get().experimental ?? this.experimentalFeatures; + } setup( core: CoreSetup, @@ -331,6 +338,7 @@ export class DiscoverPlugin history: this.historyService.getHistory(), scopedHistory: this.scopedHistory, urlTracker: this.urlTracker!, + profilesManager: this.createProfilesManager(), setHeaderActionMenu: params.setHeaderActionMenu, }); @@ -344,10 +352,11 @@ export class DiscoverPlugin const customizationContext$: Observable = services.chrome .getActiveSolutionNavId$() .pipe( - map((navId) => ({ + map((solutionNavId) => ({ ...defaultCustomizationContext, + solutionNavId, inlineTopNav: - this.inlineTopNav.get(navId) ?? + this.inlineTopNav.get(solutionNavId) ?? this.inlineTopNav.get(null) ?? defaultCustomizationContext.inlineTopNav, })) @@ -412,10 +421,7 @@ export class DiscoverPlugin } start(core: CoreStart, plugins: DiscoverStartPlugins): DiscoverStart { - // we need to register the application service at setup, but to render it - // there are some start dependencies necessary, for this reason - // initializeServices are assigned at start and used - // when the application/embeddable is mounted + this.registerProfiles(); const viewSavedSearchAction = new ViewSavedSearchAction(core.application, this.locator!); @@ -423,7 +429,6 @@ export class DiscoverPlugin plugins.uiActions.registerTrigger(SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER); injectTruncateStyles(core.uiSettings.get(TRUNCATE_MAX_HEIGHT)); - const getDiscoverServicesInternal = () => this.getDiscoverServices(core, plugins); const isEsqlEnabled = core.uiSettings.get(ENABLE_ESQL); if (plugins.share && this.locator && isEsqlEnabled) { @@ -435,6 +440,10 @@ export class DiscoverPlugin ); } + const getDiscoverServicesInternal = () => { + return this.getDiscoverServices(core, plugins, this.createEmptyProfilesManager()); + }; + return { locator: this.locator, DiscoverContainer: (props: DiscoverContainerProps) => ( @@ -449,7 +458,34 @@ export class DiscoverPlugin } } - private getDiscoverServices = (core: CoreStart, plugins: DiscoverStartPlugins) => { + private registerProfiles() { + // TODO: Conditionally register example profiles for functional testing in a follow up PR + // this.rootProfileService.registerProvider(o11yRootProfileProvider); + // this.dataSourceProfileService.registerProvider(logsDataSourceProfileProvider); + // this.documentProfileService.registerProvider(logDocumentProfileProvider); + } + + private createProfilesManager() { + return new ProfilesManager( + this.rootProfileService, + this.dataSourceProfileService, + this.documentProfileService + ); + } + + private createEmptyProfilesManager() { + return new ProfilesManager( + new RootProfileService(), + new DataSourceProfileService(), + new DocumentProfileService() + ); + } + + private getDiscoverServices = ( + core: CoreStart, + plugins: DiscoverStartPlugins, + profilesManager = this.createProfilesManager() + ) => { return buildServices({ core, plugins, @@ -459,6 +495,7 @@ export class DiscoverPlugin singleDocLocator: this.singleDocLocator!, history: this.historyService.getHistory(), urlTracker: this.urlTracker!, + profilesManager, }); }; diff --git a/x-pack/plugins/observability_solution/logs_shared/public/components/log_ai_assistant/index.tsx b/x-pack/plugins/observability_solution/logs_shared/public/components/log_ai_assistant/index.tsx index 465f1f3f66ef..484af6e4a080 100644 --- a/x-pack/plugins/observability_solution/logs_shared/public/components/log_ai_assistant/index.tsx +++ b/x-pack/plugins/observability_solution/logs_shared/public/components/log_ai_assistant/index.tsx @@ -22,12 +22,12 @@ export function createLogAIAssistant({ export const createLogsAIAssistantRenderer = (LogAIAssistantRender: ReturnType) => ({ doc }: ObservabilityLogsAIAssistantFeatureRenderDeps) => { - const mappedDoc = useMemo( + const mappedDoc = useMemo( () => ({ fields: Object.entries(doc.flattened).map(([field, value]) => ({ field, - value, - })) as LogAIAssistantDocument['fields'], + value: Array.isArray(value) ? value : [value], + })), }), [doc] ); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index cda07fb99351..8d0551242446 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -2322,10 +2322,6 @@ "discover.backToTopLinkText": "Revenir en haut de la page.", "discover.badge.readOnly.text": "Lecture seule", "discover.badge.readOnly.tooltip": "Impossible d’enregistrer les recherches", - "discover.confirmDataViewSave.cancel": "Annuler", - "discover.confirmDataViewSave.message": "L'action que vous avez choisie requiert une vue de données enregistrée.", - "discover.confirmDataViewSave.saveAndContinue": "Enregistrer et continuer", - "discover.confirmDataViewSave.title": "Enregistrer la vue de données", "discover.context.breadcrumb": "Documents relatifs", "discover.context.failedToLoadAnchorDocumentDescription": "Échec de chargement du document ancré", "discover.context.failedToLoadAnchorDocumentErrorDescription": "Le document ancré n’a pas pu être chargé.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1695925ab11e..db6337bae195 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2319,10 +2319,6 @@ "discover.backToTopLinkText": "最上部へ戻る。", "discover.badge.readOnly.text": "読み取り専用", "discover.badge.readOnly.tooltip": "検索を保存できません", - "discover.confirmDataViewSave.cancel": "キャンセル", - "discover.confirmDataViewSave.message": "選択したアクションでは、保存されたデータビューが必要です。", - "discover.confirmDataViewSave.saveAndContinue": "保存して続行", - "discover.confirmDataViewSave.title": "データビューを保存", "discover.context.breadcrumb": "周りのドキュメント", "discover.context.failedToLoadAnchorDocumentDescription": "アンカードキュメントの読み込みに失敗しました", "discover.context.failedToLoadAnchorDocumentErrorDescription": "アンカードキュメントの読み込みに失敗しました。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3c8478304226..b8c07755ed31 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2323,10 +2323,6 @@ "discover.backToTopLinkText": "返回顶部。", "discover.badge.readOnly.text": "只读", "discover.badge.readOnly.tooltip": "无法保存搜索", - "discover.confirmDataViewSave.cancel": "取消", - "discover.confirmDataViewSave.message": "您选择的操作需要已保存的数据视图。", - "discover.confirmDataViewSave.saveAndContinue": "保存并继续", - "discover.confirmDataViewSave.title": "保存数据视图。", "discover.context.breadcrumb": "周围文档", "discover.context.failedToLoadAnchorDocumentDescription": "无法加载定位点文档", "discover.context.failedToLoadAnchorDocumentErrorDescription": "无法加载定位点文档。",