diff --git a/src/plugins/discover/kibana.jsonc b/src/plugins/discover/kibana.jsonc index 308e6cda10036b..2a99c4b1ff7f29 100644 --- a/src/plugins/discover/kibana.jsonc +++ b/src/plugins/discover/kibana.jsonc @@ -38,7 +38,8 @@ "savedObjectsTaggingOss", "lens", "noDataPage", - "globalSearch" + "globalSearch", + "serverless" ], "requiredBundles": ["kibanaUtils", "kibanaReact", "unifiedSearch"], "extraPublicDirs": ["common"] diff --git a/src/plugins/discover/public/__mocks__/discover_state.mock.ts b/src/plugins/discover/public/__mocks__/discover_state.mock.ts index 3e0a36de1d595e..60e790cd752181 100644 --- a/src/plugins/discover/public/__mocks__/discover_state.mock.ts +++ b/src/plugins/discover/public/__mocks__/discover_state.mock.ts @@ -23,6 +23,10 @@ export function getDiscoverStateMock({ const container = getDiscoverStateContainer({ services: discoverServiceMock, history, + customizationContext: { + displayMode: 'standalone', + showLogExplorerTabs: false, + }, }); container.savedSearchState.set( savedSearch ? savedSearch : isTimeBased ? savedSearchMockWithTimeField : savedSearchMock diff --git a/src/plugins/discover/public/application/discover_router.test.tsx b/src/plugins/discover/public/application/discover_router.test.tsx index d151963ce921a1..3d58d065129697 100644 --- a/src/plugins/discover/public/application/discover_router.test.tsx +++ b/src/plugins/discover/public/application/discover_router.test.tsx @@ -11,13 +11,19 @@ import { Redirect, RouteProps } from 'react-router-dom'; import { Route } from '@kbn/shared-ux-router'; import { createSearchSessionMock } from '../__mocks__/search_session'; import { discoverServiceMock as mockDiscoverServices } from '../__mocks__/services'; -import { CustomDiscoverRoutes, DiscoverRouter, DiscoverRoutes } from './discover_router'; +import { + CustomDiscoverRoutes, + DiscoverRouter, + DiscoverRoutes, + DiscoverRoutesProps, +} from './discover_router'; import { DiscoverMainRoute } from './main'; import { SingleDocRoute } from './doc'; import { ContextAppRoute } from './context'; import { createProfileRegistry } from '../customizations/profile_registry'; import { addProfile } from '../../common/customizations'; import { NotFoundRoute } from './not_found'; +import type { DiscoverCustomizationContext } from './types'; let mockProfile: string | undefined; @@ -43,9 +49,15 @@ const gatherRoutes = (wrapper: ShallowWrapper) => { }); }; -const props = { +const customizationContext: DiscoverCustomizationContext = { + displayMode: 'standalone', + showLogExplorerTabs: false, +}; + +const props: DiscoverRoutesProps = { isDev: false, customizationCallbacks: [], + customizationContext, }; describe('DiscoverRoutes', () => { @@ -147,12 +159,17 @@ describe('CustomDiscoverRoutes', () => { it('should show DiscoverRoutes for a valid profile', () => { mockProfile = 'test'; const component = shallow( - + ); expect(component.find(DiscoverRoutes).getElement()).toMatchObject( ); @@ -161,7 +178,11 @@ describe('CustomDiscoverRoutes', () => { it('should show NotFoundRoute for an invalid profile', () => { mockProfile = 'invalid'; const component = shallow( - + ); expect(component.find(NotFoundRoute).getElement()).toMatchObject(); }); @@ -178,6 +199,7 @@ describe('DiscoverRouter', () => { services={mockDiscoverServices} history={history} profileRegistry={profileRegistry} + customizationContext={customizationContext} isDev={props.isDev} /> ); @@ -186,13 +208,21 @@ describe('DiscoverRouter', () => { it('should show DiscoverRoutes component for / route', () => { expect(pathMap['/']).toMatchObject( - + ); }); it(`should show CustomDiscoverRoutes component for ${profilePath} route`, () => { expect(pathMap[profilePath]).toMatchObject( - + ); }); }); diff --git a/src/plugins/discover/public/application/discover_router.tsx b/src/plugins/discover/public/application/discover_router.tsx index 73c99d50b6fa69..5e146a768baa11 100644 --- a/src/plugins/discover/public/application/discover_router.tsx +++ b/src/plugins/discover/public/application/discover_router.tsx @@ -21,10 +21,12 @@ import { ViewAlertRoute } from './view_alert'; import type { CustomizationCallback } from '../customizations'; import type { DiscoverProfileRegistry } from '../customizations/profile_registry'; import { addProfile } from '../../common/customizations'; +import type { DiscoverCustomizationContext } from './types'; -interface DiscoverRoutesProps { +export interface DiscoverRoutesProps { prefix?: string; customizationCallbacks: CustomizationCallback[]; + customizationContext: DiscoverCustomizationContext; isDev: boolean; } @@ -66,6 +68,7 @@ export const DiscoverRoutes = ({ prefix, ...mainRouteProps }: DiscoverRoutesProp interface CustomDiscoverRoutesProps { profileRegistry: DiscoverProfileRegistry; + customizationContext: DiscoverCustomizationContext; isDev: boolean; } @@ -92,6 +95,7 @@ export const CustomDiscoverRoutes = ({ profileRegistry, ...props }: CustomDiscov export interface DiscoverRouterProps { services: DiscoverServices; profileRegistry: DiscoverProfileRegistry; + customizationContext: DiscoverCustomizationContext; history: History; isDev: boolean; } diff --git a/src/plugins/discover/public/application/index.tsx b/src/plugins/discover/public/application/index.tsx index a9571e08e21ad0..2e26b32973210f 100644 --- a/src/plugins/discover/public/application/index.tsx +++ b/src/plugins/discover/public/application/index.tsx @@ -12,15 +12,23 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { DiscoverRouter } from './discover_router'; import { DiscoverServices } from '../build_services'; import type { DiscoverProfileRegistry } from '../customizations/profile_registry'; +import type { DiscoverCustomizationContext } from './types'; export interface RenderAppProps { element: HTMLElement; services: DiscoverServices; profileRegistry: DiscoverProfileRegistry; + customizationContext: DiscoverCustomizationContext; isDev: boolean; } -export const renderApp = ({ element, services, profileRegistry, isDev }: RenderAppProps) => { +export const renderApp = ({ + element, + services, + profileRegistry, + customizationContext, + isDev, +}: RenderAppProps) => { const { history: getHistory, capabilities, chrome, data, core } = services; const history = getHistory(); @@ -39,6 +47,7 @@ export const renderApp = ({ element, services, profileRegistry, isDev }: RenderA , diff --git a/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts b/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts index 5a5c2bb0396835..54d2dc5da0084d 100644 --- a/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts +++ b/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts @@ -15,7 +15,7 @@ import { createHashHistory } from 'history'; import { SavedSearch } from '@kbn/saved-search-plugin/public'; import { buildDataTableRecordList } from '@kbn/discover-utils'; import { esHitsMock } from '@kbn/discover-utils/src/__mocks__'; -import { FetchStatus } from '../../../../types'; +import { DiscoverCustomizationContext, FetchStatus } from '../../../../types'; import { AvailableFields$, DataDocuments$, @@ -124,11 +124,17 @@ function getSavedSearch(dataView: DataView) { } as unknown as SavedSearch; } +const customizationContext: DiscoverCustomizationContext = { + displayMode: 'standalone', + showLogExplorerTabs: false, +}; + export function getDocumentsLayoutProps(dataView: DataView) { const stateContainer = getDiscoverStateContainer({ history: createHashHistory(), savedSearch: getSavedSearch(dataView), services, + customizationContext, }); stateContainer.appState.set({ columns: ['name', 'message', 'bytes'], @@ -153,6 +159,7 @@ export const getPlainRecordLayoutProps = (dataView: DataView) => { history: createHashHistory(), savedSearch: getSavedSearch(dataView), services, + customizationContext, }); stateContainer.appState.set({ columns: ['name', 'message', 'bytes'], diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss index 55972b4f7f6291..352a1f803bc352 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss @@ -11,6 +11,10 @@ discover-app { .dscPage { @include euiBreakpoint('m', 'l', 'xl') { @include kibanaFullBodyHeight(); + + &.dscPage--serverless { + @include kibanaFullBodyHeight($euiSize * 3); + } } flex-direction: column; diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index b2d3a4b5058ee9..622ef340ffa984 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -37,7 +37,6 @@ import { DiscoverStateContainer } from '../../services/discover_state'; import { VIEW_MODE } from '../../../../../common/constants'; import { useInternalStateSelector } from '../../services/discover_internal_state_container'; import { useAppStateSelector } from '../../services/discover_app_state_container'; -import { useInspector } from '../../hooks/use_inspector'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { DiscoverNoResults } from '../no_results'; import { LoadingSpinner } from '../loading_spinner/loading_spinner'; @@ -73,8 +72,8 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { filterManager, history, spaces, - inspector, docLinks, + serverless, } = useDiscoverServices(); const { euiTheme } = useEuiTheme(); const pageBackgroundColor = useEuiBackgroundColor('plain'); @@ -118,11 +117,6 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { [dataState.fetchStatus, dataState.foundDocuments] ); - const onOpenInspector = useInspector({ - inspector, - stateContainer, - }); - const { columns: currentColumns, onAddColumn, @@ -243,7 +237,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { return ( ({ ...jest.requireActual('@kbn/kibana-react-plugin/public'), - useKibana: () => ({ - services: mockDiscoverService, - }), + useKibana: jest.fn(), })); const MockCustomSearchBar: typeof mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu = @@ -77,15 +75,14 @@ function getProps( return { stateContainer, - query: {} as Query, savedQuery: '', updateQuery: jest.fn(), - onOpenInspector: jest.fn(), onFieldEdited: jest.fn(), - isPlainRecord: false, }; } +const mockUseKibana = useKibana as jest.Mock; + describe('Discover topnav component', () => { beforeEach(() => { mockTopNavCustomization.defaultMenu = undefined; @@ -107,6 +104,10 @@ describe('Discover topnav component', () => { throw new Error(`Unknown customization id: ${id}`); } }); + + mockUseKibana.mockReturnValue({ + services: mockDiscoverService, + }); }); test('generated config of TopNavMenu config is correct when discover save permissions are assigned', () => { @@ -280,4 +281,38 @@ describe('Discover topnav component', () => { expect(topNav.prop('dataViewPickerComponentProps')).toBeUndefined(); }); }); + + describe('serverless', () => { + it('should render top nav when serverless plugin is not defined', () => { + const props = getProps(); + const component = mountWithIntl( + + + + ); + const searchBar = component.find(mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu); + expect(searchBar.prop('badges')).toBeDefined(); + expect(searchBar.prop('config')).toBeDefined(); + expect(searchBar.prop('setMenuMountPoint')).toBeDefined(); + }); + + it('should not render top nav when serverless plugin is defined', () => { + mockUseKibana.mockReturnValue({ + services: { + ...mockDiscoverService, + serverless: true, + }, + }); + const props = getProps(); + const component = mountWithIntl( + + + + ); + const searchBar = component.find(mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu); + expect(searchBar.prop('badges')).toBeUndefined(); + expect(searchBar.prop('config')).toBeUndefined(); + expect(searchBar.prop('setMenuMountPoint')).toBeUndefined(); + }); + }); }); diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx index 51484519ee7ac3..3e8d58297783cd 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -5,8 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import useObservable from 'react-use/lib/useObservable'; import type { Query, TimeRange, AggregateQuery } from '@kbn/es-query'; import { DataViewType, type DataView } from '@kbn/data-views-plugin/public'; import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public'; @@ -14,51 +14,48 @@ import { ENABLE_ESQL } from '@kbn/discover-utils'; import { useSavedSearchInitial } from '../../services/discover_state_provider'; import { useInternalStateSelector } from '../../services/discover_internal_state_container'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; -import { getTopNavLinks } from './get_top_nav_links'; -import { getTopNavBadges } from './get_top_nav_badges'; import { getHeaderActionMenuMounter } from '../../../../kibana_services'; import { DiscoverStateContainer } from '../../services/discover_state'; import { onSaveSearch } from './on_save_search'; import { useDiscoverCustomization } from '../../../../customizations'; import { addLog } from '../../../../utils/add_log'; +import { useAppStateSelector } from '../../services/discover_app_state_container'; +import { isTextBasedQuery } from '../../utils/is_text_based_query'; +import { useDiscoverTopNav } from './use_discover_topnav'; export interface DiscoverTopNavProps { - onOpenInspector: () => void; - query?: Query | AggregateQuery; savedQuery?: string; updateQuery: ( payload: { dateRange: TimeRange; query?: Query | AggregateQuery }, isUpdate?: boolean ) => void; stateContainer: DiscoverStateContainer; - isPlainRecord: boolean; textBasedLanguageModeErrors?: Error; textBasedLanguageModeWarning?: string; onFieldEdited: () => Promise; } export const DiscoverTopNav = ({ - onOpenInspector, - query, savedQuery, stateContainer, updateQuery, - isPlainRecord, textBasedLanguageModeErrors, textBasedLanguageModeWarning, onFieldEdited, }: DiscoverTopNavProps) => { + const query = useAppStateSelector((state) => state.query); const adHocDataViews = useInternalStateSelector((state) => state.adHocDataViews); const dataView = useInternalStateSelector((state) => state.dataView!); const savedDataViews = useInternalStateSelector((state) => state.savedDataViews); const savedSearch = useSavedSearchInitial(); + const isTextBased = useMemo(() => isTextBasedQuery(query), [query]); const showDatePicker = useMemo(() => { // always show the timepicker for text based languages return ( - isPlainRecord || - (!isPlainRecord && dataView.isTimeBased() && dataView.type !== DataViewType.ROLLUP) + isTextBased || + (!isTextBased && dataView.isTimeBased() && dataView.type !== DataViewType.ROLLUP) ); - }, [dataView, isPlainRecord]); + }, [dataView, isTextBased]); const services = useDiscoverServices(); const { dataViewEditor, navigation, dataViewFieldEditor, data, uiSettings, dataViews } = services; @@ -115,44 +112,6 @@ export const DiscoverTopNav = ({ }); }, [dataViewEditor, stateContainer]); - const topNavCustomization = useDiscoverCustomization('top_nav'); - - const hasSavedSearchChanges = useObservable(stateContainer.savedSearchState.getHasChanged$()); - const hasUnsavedChanges = - hasSavedSearchChanges && Boolean(stateContainer.savedSearchState.getId()); - const topNavBadges = useMemo( - () => - getTopNavBadges({ - stateContainer, - services, - hasUnsavedChanges, - topNavCustomization, - }), - [stateContainer, services, hasUnsavedChanges, topNavCustomization] - ); - - const topNavMenu = useMemo( - () => - getTopNavLinks({ - dataView, - services, - state: stateContainer, - onOpenInspector, - isPlainRecord, - adHocDataViews, - topNavCustomization, - }), - [ - adHocDataViews, - dataView, - isPlainRecord, - onOpenInspector, - services, - stateContainer, - topNavCustomization, - ] - ); - const onEditDataView = async (editedDataView: DataView) => { if (editedDataView.isPersisted()) { // Clear the current data view from the cache and create a new instance @@ -229,16 +188,21 @@ export const DiscoverTopNav = ({ [services, stateContainer] ); + const { topNavBadges, topNavMenu } = useDiscoverTopNav({ stateContainer }); + const topNavProps = !services.serverless && { + badges: topNavBadges, + config: topNavMenu, + setMenuMountPoint, + }; + return ( ({ + ...jest.requireActual('@kbn/kibana-react-plugin/public'), + useKibana: jest.fn(), +})); + +function getProps({ hideNavMenuItems }: { hideNavMenuItems?: boolean } = {}) { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.internalState.transitions.setDataView(dataViewMock); + + return { + stateContainer, + hideNavMenuItems, + }; +} + +const mockUseKibana = useKibana as jest.Mock; + +describe('DiscoverTopNavServerless', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseKibana.mockReturnValue({ + services: mockDiscoverService, + }); + }); + + it('should not render when serverless plugin is not defined', async () => { + const props = getProps(); + render( + + + + ); + const topNav = screen.queryByTestId('discoverTopNavServerless'); + expect(topNav).toBeNull(); + }); + + it('should render when serverless plugin is defined and displayMode is "standalone"', async () => { + mockUseKibana.mockReturnValue({ + services: { + ...mockDiscoverService, + serverless: true, + }, + }); + const props = getProps(); + render( + + + + ); + const topNav = screen.queryByTestId('discoverTopNavServerless'); + expect(topNav).not.toBeNull(); + }); + + it('should not render when serverless plugin is defined and displayMode is not "standalone"', async () => { + mockUseKibana.mockReturnValue({ + services: { + ...mockDiscoverService, + serverless: true, + }, + }); + const props = getProps(); + props.stateContainer.customizationContext.displayMode = 'embedded'; + render( + + + + ); + const topNav = screen.queryByTestId('discoverTopNavServerless'); + expect(topNav).toBeNull(); + }); + + describe('nav menu items', () => { + it('should show nav menu items when hideNavMenuItems is false', async () => { + mockUseKibana.mockReturnValue({ + services: { + ...mockDiscoverService, + serverless: true, + }, + }); + const props = getProps(); + render( + + + + ); + const topNav = screen.queryByTestId('discoverTopNavServerless'); + expect(topNav).not.toBeNull(); + await waitFor(() => { + const topNavMenuItems = screen.queryByTestId('topNavMenuItems'); + expect(topNavMenuItems).not.toBeNull(); + }); + }); + + it('should hide nav menu items when hideNavMenuItems is true', async () => { + mockUseKibana.mockReturnValue({ + services: { + ...mockDiscoverService, + serverless: true, + }, + }); + const props = getProps({ hideNavMenuItems: true }); + render( + + + + ); + const topNav = screen.queryByTestId('discoverTopNavServerless'); + expect(topNav).not.toBeNull(); + await waitFor(() => { + const topNavMenuItems = screen.queryByTestId('topNavMenuItems'); + expect(topNavMenuItems).toBeNull(); + }); + }); + }); + + describe('LogExplorerTabs', () => { + it('should render when showLogExplorerTabs is true', async () => { + mockUseKibana.mockReturnValue({ + services: { + ...mockDiscoverService, + serverless: true, + }, + }); + const props = getProps(); + props.stateContainer.customizationContext.showLogExplorerTabs = true; + render( + + + + ); + const topNav = screen.queryByTestId('discoverTopNavServerless'); + expect(topNav).not.toBeNull(); + await waitFor(() => { + const logExplorerTabs = screen.queryByTestId('logExplorerTabs'); + expect(logExplorerTabs).not.toBeNull(); + }); + }); + + it('should not render when showLogExplorerTabs is false', async () => { + mockUseKibana.mockReturnValue({ + services: { + ...mockDiscoverService, + serverless: true, + }, + }); + const props = getProps(); + render( + + + + ); + const topNav = screen.queryByTestId('discoverTopNavServerless'); + expect(topNav).not.toBeNull(); + await waitFor(() => { + const logExplorerTabs = screen.queryByTestId('logExplorerTabs'); + expect(logExplorerTabs).toBeNull(); + }); + }); + }); +}); diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav_serverless.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav_serverless.tsx new file mode 100644 index 00000000000000..800ceeae760132 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav_serverless.tsx @@ -0,0 +1,53 @@ +/* + * 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 { EuiHeader, EuiHeaderSection, EuiHeaderSectionItem } from '@elastic/eui'; +import { TopNavMenuBadges, TopNavMenuItems } from '@kbn/navigation-plugin/public'; +import { LogExplorerTabs } from '../../../../components/log_explorer_tabs'; +import { useDiscoverServices } from '../../../../hooks/use_discover_services'; +import { useDiscoverTopNav } from './use_discover_topnav'; +import type { DiscoverStateContainer } from '../../services/discover_state'; + +export const DiscoverTopNavServerless = ({ + stateContainer, + hideNavMenuItems, +}: { + stateContainer: DiscoverStateContainer; + hideNavMenuItems?: boolean; +}) => { + const { customizationContext } = stateContainer; + const services = useDiscoverServices(); + const { topNavBadges, topNavMenu } = useDiscoverTopNav({ stateContainer }); + + if (!services.serverless || customizationContext.displayMode !== 'standalone') { + return null; + } + + return ( + + {customizationContext.showLogExplorerTabs && ( + + + + + + )} + {!hideNavMenuItems && ( + + + + + + + + + )} + + ); +}; diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.test.ts b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.test.ts index 4e3e7068f883d0..e83f517242e373 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.test.ts +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.test.ts @@ -27,7 +27,7 @@ test('getTopNavLinks result', () => { onOpenInspector: jest.fn(), services, state, - isPlainRecord: false, + isTextBased: false, adHocDataViews: [], topNavCustomization: undefined, }); @@ -80,7 +80,7 @@ test('getTopNavLinks result for ES|QL mode', () => { onOpenInspector: jest.fn(), services, state, - isPlainRecord: true, + isTextBased: true, adHocDataViews: [], topNavCustomization: undefined, }); diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx index ef2a6fb9ad28b7..0f066760b9e2eb 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx @@ -27,15 +27,15 @@ export const getTopNavLinks = ({ services, state, onOpenInspector, - isPlainRecord, + isTextBased, adHocDataViews, topNavCustomization, }: { - dataView: DataView; + dataView: DataView | undefined; services: DiscoverServices; state: DiscoverStateContainer; onOpenInspector: () => void; - isPlainRecord: boolean; + isTextBased: boolean; adHocDataViews: DataView[]; topNavCustomization: TopNavCustomization | undefined; }): TopNavMenuData[] => { @@ -53,7 +53,7 @@ export const getTopNavLinks = ({ services, stateContainer: state, adHocDataViews, - isPlainRecord, + isPlainRecord: isTextBased, }); }, testId: 'discoverAlertsButton', @@ -126,7 +126,7 @@ export const getTopNavLinks = ({ savedSearch.searchSource, state.appState.getState(), services, - isPlainRecord + isTextBased ); const { locator } = services; diff --git a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.test.tsx index 5506010528e05c..1d422baf238fff 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.test.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.test.tsx @@ -25,6 +25,10 @@ function getStateContainer({ dataView }: { dataView?: DataView } = {}) { const stateContainer = getDiscoverStateContainer({ services: discoverServiceMock, history, + customizationContext: { + displayMode: 'standalone', + showLogExplorerTabs: false, + }, }); stateContainer.savedSearchState.set(savedSearch); stateContainer.appState.getState = jest.fn(() => ({ diff --git a/src/plugins/discover/public/application/main/components/top_nav/use_discover_topnav.ts b/src/plugins/discover/public/application/main/components/top_nav/use_discover_topnav.ts new file mode 100644 index 00000000000000..7304fd941c4a38 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/top_nav/use_discover_topnav.ts @@ -0,0 +1,79 @@ +/* + * 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 { useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { useDiscoverCustomization } from '../../../../customizations'; +import { useDiscoverServices } from '../../../../hooks/use_discover_services'; +import { useInspector } from '../../hooks/use_inspector'; +import { useAppStateSelector } from '../../services/discover_app_state_container'; +import { useInternalStateSelector } from '../../services/discover_internal_state_container'; +import type { DiscoverStateContainer } from '../../services/discover_state'; +import { isTextBasedQuery } from '../../utils/is_text_based_query'; +import { getTopNavBadges } from './get_top_nav_badges'; +import { getTopNavLinks } from './get_top_nav_links'; + +export const useDiscoverTopNav = ({ + stateContainer, +}: { + stateContainer: DiscoverStateContainer; +}) => { + const services = useDiscoverServices(); + const topNavCustomization = useDiscoverCustomization('top_nav'); + const hasSavedSearchChanges = useObservable(stateContainer.savedSearchState.getHasChanged$()); + const hasUnsavedChanges = Boolean( + hasSavedSearchChanges && stateContainer.savedSearchState.getId() + ); + + const topNavBadges = useMemo( + () => + getTopNavBadges({ + stateContainer, + services, + hasUnsavedChanges, + topNavCustomization, + }), + [stateContainer, services, hasUnsavedChanges, topNavCustomization] + ); + + const dataView = useInternalStateSelector((state) => state.dataView); + const adHocDataViews = useInternalStateSelector((state) => state.adHocDataViews); + const query = useAppStateSelector((state) => state.query); + const isTextBased = useMemo(() => isTextBasedQuery(query), [query]); + const onOpenInspector = useInspector({ + inspector: services.inspector, + stateContainer, + }); + + const topNavMenu = useMemo( + () => + getTopNavLinks({ + dataView, + services, + state: stateContainer, + onOpenInspector, + isTextBased, + adHocDataViews, + topNavCustomization, + }), + [ + adHocDataViews, + dataView, + isTextBased, + onOpenInspector, + services, + stateContainer, + topNavCustomization, + ] + ); + + return { + topNavMenu, + topNavBadges, + }; +}; diff --git a/src/plugins/discover/public/application/main/discover_main_app.tsx b/src/plugins/discover/public/application/main/discover_main_app.tsx index dbc1db449b0300..d10e74a66523b9 100644 --- a/src/plugins/discover/public/application/main/discover_main_app.tsx +++ b/src/plugins/discover/public/application/main/discover_main_app.tsx @@ -18,7 +18,6 @@ import { useSavedSearchAliasMatchRedirect } from '../../hooks/saved_search_alias import { useSavedSearchInitial } from './services/discover_state_provider'; import { useAdHocDataViews } from './hooks/use_adhoc_data_views'; import { useTextBasedQueryLanguage } from './hooks/use_text_based_query_language'; -import type { DiscoverDisplayMode } from '../types'; import { addLog } from '../../utils/add_log'; const DiscoverLayoutMemoized = React.memo(DiscoverLayout); @@ -28,11 +27,10 @@ export interface DiscoverMainProps { * Central state container */ stateContainer: DiscoverStateContainer; - mode?: DiscoverDisplayMode; } export function DiscoverMainApp(props: DiscoverMainProps) { - const { stateContainer, mode = 'standalone' } = props; + const { stateContainer } = props; const savedSearch = useSavedSearchInitial(); const services = useDiscoverServices(); const { chrome, docLinks, data, spaces, history } = services; @@ -65,12 +63,18 @@ export function DiscoverMainApp(props: DiscoverMainProps) { * SavedSearch dependent initializing */ useEffect(() => { - if (mode === 'standalone') { + if (stateContainer.customizationContext.displayMode === 'standalone') { const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; chrome.docTitle.change(`Discover${pageTitleSuffix}`); setBreadcrumbs({ titleBreadcrumbText: savedSearch.title, services }); } - }, [mode, chrome.docTitle, savedSearch.id, savedSearch.title, services]); + }, [ + chrome.docTitle, + savedSearch.id, + savedSearch.title, + services, + stateContainer.customizationContext.displayMode, + ]); useEffect(() => { addHelpMenuToAppChrome(chrome, docLinks); 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 560f998b4bd94e..63296bfb927e93 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 @@ -11,7 +11,7 @@ import { waitFor } from '@testing-library/react'; import { setHeaderActionMenuMounter, setScopedHistory } from '../../kibana_services'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { discoverServiceMock } from '../../__mocks__/services'; -import { DiscoverMainRoute } from './discover_main_route'; +import { DiscoverMainRoute, MainRouteProps } from './discover_main_route'; import { MemoryRouter } from 'react-router-dom'; import { DiscoverMainApp } from './discover_main_app'; import { findTestSubject } from '@elastic/eui/lib/test'; @@ -20,6 +20,7 @@ import { createCustomizationService, DiscoverCustomizationService, } from '../../customizations/customization_service'; +import { DiscoverTopNavServerless } from './components/top_nav/discover_topnav_serverless'; let mockCustomizationService: DiscoverCustomizationService | undefined; @@ -98,12 +99,26 @@ describe('DiscoverMainRoute', () => { expect(component.find(DiscoverMainApp).exists()).toBe(true); }); }); + + test('should pass hideNavMenuItems=true to DiscoverTopNavServerless while loading', async () => { + const component = mountComponent(true, true); + expect(component.find(DiscoverTopNavServerless).prop('hideNavMenuItems')).toBe(true); + await waitFor(() => { + expect(component.update().find(DiscoverTopNavServerless).prop('hideNavMenuItems')).toBe( + false + ); + }); + }); }); const mountComponent = (hasESData = true, hasUserDataView = true) => { - const props = { + const props: MainRouteProps = { isDev: false, customizationCallbacks: [], + customizationContext: { + displayMode: 'standalone', + showLogExplorerTabs: false, + }, }; return mountWithIntl( 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 9e46f2134acd8c..1bb24661da0cbf 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.tsx @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + import React, { useEffect, useState, memo, useCallback, useMemo } from 'react'; import { useParams, useHistory } from 'react-router-dom'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -34,7 +35,8 @@ import { DiscoverCustomizationProvider, useDiscoverCustomizationService, } from '../../customizations'; -import type { DiscoverDisplayMode } from '../types'; +import type { DiscoverCustomizationContext } from '../types'; +import { DiscoverTopNavServerless } from './components/top_nav/discover_topnav_serverless'; const DiscoverMainAppMemoized = memo(DiscoverMainApp); @@ -45,10 +47,13 @@ interface DiscoverLandingParams { export interface MainRouteProps { customizationCallbacks: CustomizationCallback[]; isDev: boolean; - mode?: DiscoverDisplayMode; + customizationContext: DiscoverCustomizationContext; } -export function DiscoverMainRoute({ customizationCallbacks, mode = 'standalone' }: MainRouteProps) { +export function DiscoverMainRoute({ + customizationCallbacks, + customizationContext, +}: MainRouteProps) { const history = useHistory(); const services = useDiscoverServices(); const { @@ -60,12 +65,11 @@ export function DiscoverMainRoute({ customizationCallbacks, mode = 'standalone' dataViewEditor, } = services; const { id: savedSearchId } = useParams(); - const stateContainer = useSingleton(() => getDiscoverStateContainer({ history, services, - mode, + customizationContext, }) ); const { customizationService, isInitialized: isCustomizationServiceInitialized } = @@ -150,7 +154,7 @@ export function DiscoverMainRoute({ customizationCallbacks, mode = 'standalone' dataView: nextDataView, dataViewSpec: historyLocationState?.dataViewSpec, }); - if (mode === 'standalone') { + if (customizationContext.displayMode === 'standalone') { if (currentSavedSearch?.id) { chrome.recentlyAccessed.add( getSavedSearchFullPathUrl(currentSavedSearch.id), @@ -195,32 +199,20 @@ export function DiscoverMainRoute({ customizationCallbacks, mode = 'standalone' }, [ checkData, - stateContainer, + stateContainer.actions, savedSearchId, historyLocationState?.dataViewSpec, - chrome, + customizationContext.displayMode, services, + chrome.recentlyAccessed, history, core.application.navigateToApp, core.theme, basePath, toastNotifications, - mode, ] ); - const onDataViewCreated = useCallback( - async (nextDataView: unknown) => { - if (nextDataView) { - setLoading(true); - setShowNoDataPage(false); - setError(undefined); - await loadSavedSearch(nextDataView as DataView); - } - }, - [loadSavedSearch] - ); - useEffect(() => { if (!isCustomizationServiceInitialized) return; @@ -244,8 +236,20 @@ export function DiscoverMainRoute({ customizationCallbacks, mode = 'standalone' }, }); - if (showNoDataPage) { - const analyticsServices = { + const onDataViewCreated = useCallback( + async (nextDataView: unknown) => { + if (nextDataView) { + setLoading(true); + setShowNoDataPage(false); + setError(undefined); + await loadSavedSearch(nextDataView as DataView); + } + }, + [loadSavedSearch] + ); + + const noDataDependencies = useMemo( + () => ({ coreStart: core, dataViews: { ...data.dataViews, @@ -260,27 +264,53 @@ export function DiscoverMainRoute({ customizationCallbacks, mode = 'standalone' }, dataViewEditor, noDataPage: services.noDataPage, - }; + }), + [core, data.dataViews, dataViewEditor, hasESData, hasUserDataView, services.noDataPage] + ); - return ( - - - - ); - } + const loadingIndicator = useMemo( + () => , + [hasCustomBranding] + ); + + const mainContent = useMemo(() => { + if (showNoDataPage) { + return ( + + + + ); + } + + if (loading) { + return loadingIndicator; + } + + return ; + }, [ + loading, + loadingIndicator, + noDataDependencies, + onDataViewCreated, + showNoDataPage, + stateContainer, + ]); if (error) { return ; } - if (loading || !customizationService) { - return ; + if (!customizationService) { + return loadingIndicator; } return ( - + <> + + {mainContent} + ); diff --git a/src/plugins/discover/public/application/main/services/discover_state.test.ts b/src/plugins/discover/public/application/main/services/discover_state.test.ts index c946ac70bbe62f..a14a7d3b797c67 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.test.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.test.ts @@ -22,9 +22,9 @@ import { } from '../../../__mocks__/saved_search'; import { discoverServiceMock } from '../../../__mocks__/services'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; -import { DiscoverAppStateContainer } from './discover_app_state_container'; +import type { DiscoverAppStateContainer } from './discover_app_state_container'; import { waitFor } from '@testing-library/react'; -import { FetchStatus } from '../../types'; +import { DiscoverCustomizationContext, FetchStatus } from '../../types'; import { dataViewAdHoc, dataViewComplexMock } from '../../../__mocks__/data_view_complex'; import { copySavedSearch } from './discover_saved_search_container'; @@ -34,6 +34,11 @@ const startSync = (appState: DiscoverAppStateContainer) => { return stop; }; +const customizationContext: DiscoverCustomizationContext = { + displayMode: 'standalone', + showLogExplorerTabs: false, +}; + async function getState( url: string = '/', { savedSearch, isEmptyUrl }: { savedSearch?: SavedSearch; isEmptyUrl?: boolean } = {} @@ -51,6 +56,7 @@ async function getState( const nextState = getDiscoverStateContainer({ services: discoverServiceMock, history: nextHistory, + customizationContext, }); nextState.appState.isEmptyURL = jest.fn(() => isEmptyUrl ?? true); jest.spyOn(nextState.dataState, 'fetch'); @@ -87,9 +93,10 @@ describe('Test discover state', () => { state = getDiscoverStateContainer({ services: discoverServiceMock, history, + customizationContext, }); state.savedSearchState.set(savedSearchMock); - await state.appState.update({}, true); + state.appState.update({}, true); stopSync = startSync(state.appState); }); afterEach(() => { @@ -137,7 +144,10 @@ describe('Test discover state', () => { test('pauseAutoRefreshInterval sets refreshInterval.pause to true', async () => { history.push('/#?_g=(refreshInterval:(pause:!f,value:5000))'); expect(getCurrentUrl()).toBe('/#?_g=(refreshInterval:(pause:!f,value:5000))'); - await state.actions.setDataView(dataViewMock); + // TODO: state.actions.setDataView should be async because it calls pauseAutoRefreshInterval which is async. + // I found this bug while removing unnecessary awaits, but it will need to be fixed in a follow up PR. + state.actions.setDataView(dataViewMock); + await new Promise(process.nextTick); expect(getCurrentUrl()).toBe('/#?_g=(refreshInterval:(pause:!t,value:5000))'); }); }); @@ -190,6 +200,7 @@ describe('Test createSearchSessionRestorationDataProvider', () => { const discoverStateContainer = getDiscoverStateContainer({ services: discoverServiceMock, history, + customizationContext, }); discoverStateContainer.appState.update({ index: savedSearchMock.searchSource.getField('index')!.id, @@ -661,9 +672,9 @@ describe('Test discover state actions', () => { const { state } = await getState('/', { savedSearch: savedSearchMock }); const unsubscribe = state.actions.initializeAndSync(); await state.actions.loadSavedSearch({ savedSearchId: savedSearchMock.id }); - await state.savedSearchState.update({ nextState: { hideChart: true } }); + state.savedSearchState.update({ nextState: { hideChart: true } }); expect(state.savedSearchState.getState().hideChart).toBe(true); - await state.actions.onOpenSavedSearch(savedSearchMock.id!); + state.actions.onOpenSavedSearch(savedSearchMock.id!); expect(state.savedSearchState.getState().hideChart).toBe(undefined); unsubscribe(); }); @@ -756,16 +767,21 @@ describe('Test discover state with embedded mode', () => { state = getDiscoverStateContainer({ services: discoverServiceMock, history, - mode: 'embedded', + customizationContext: { + ...customizationContext, + displayMode: 'embedded', + }, }); state.savedSearchState.set(savedSearchMock); - await state.appState.update({}, true); + state.appState.update({}, true); stopSync = startSync(state.appState); }); + afterEach(() => { stopSync(); stopSync = () => {}; }); + test('setting app state and syncing to URL', async () => { state.appState.update({ index: 'modified' }); await new Promise(process.nextTick); diff --git a/src/plugins/discover/public/application/main/services/discover_state.ts b/src/plugins/discover/public/application/main/services/discover_state.ts index b3abfed5e6eccf..507076391c3846 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.ts @@ -26,7 +26,7 @@ import { merge } from 'rxjs'; import { AggregateQuery, Query, TimeRange } from '@kbn/es-query'; import { loadSavedSearch as loadSavedSearchFn } from './load_saved_search'; import { restoreStateFromSavedSearch } from '../../../services/saved_searches/restore_from_saved_search'; -import { DiscoverDisplayMode, FetchStatus } from '../../types'; +import { DiscoverCustomizationContext, FetchStatus } from '../../types'; import { changeDataView } from '../hooks/utils/change_data_view'; import { buildStateSubscribe } from '../hooks/utils/build_state_subscribe'; import { addLog } from '../../../utils/add_log'; @@ -67,11 +67,10 @@ interface DiscoverStateContainerParams { * core ui settings service */ services: DiscoverServices; - /* - * mode in which discover is running - * - * */ - mode?: DiscoverDisplayMode; + /** + * Context object for customization related properties + */ + customizationContext: DiscoverCustomizationContext; } export interface LoadParams { @@ -94,7 +93,6 @@ export interface DiscoverStateContainer { * Global State, the _g part of the URL */ globalState: DiscoverGlobalStateContainer; - /** * App state, the _a part of the URL */ @@ -119,6 +117,10 @@ export interface DiscoverStateContainer { * Service for handling search sessions */ searchSessionManager: DiscoverSearchSessionManager; + /** + * Context object for customization related properties + */ + customizationContext: DiscoverCustomizationContext; /** * Complex functions to update multiple containers from UI */ @@ -190,7 +192,7 @@ export interface DiscoverStateContainer { * When saving a saved search with an ad hoc data view, a new id needs to be generated for the data view * This is to prevent duplicate ids messing with our system */ - updateAdHocDataViewId: () => void; + updateAdHocDataViewId: () => Promise; }; } @@ -201,7 +203,7 @@ export interface DiscoverStateContainer { export function getDiscoverStateContainer({ history, services, - mode = 'standalone', + customizationContext, }: DiscoverStateContainerParams): DiscoverStateContainer { const storeInSessionStorage = services.uiSettings.get('state:storeInSessionStorage'); const toasts = services.core.notifications.toasts; @@ -212,7 +214,7 @@ export function getDiscoverStateContainer({ const stateStorage = createKbnUrlStateStorage({ useHash: storeInSessionStorage, history, - useHashQuery: mode !== 'embedded', + useHashQuery: customizationContext.displayMode !== 'embedded', ...(toasts && withNotifyOnErrors(toasts)), }); @@ -475,6 +477,7 @@ export function getDiscoverStateContainer({ savedSearchState: savedSearchContainer, stateStorage, searchSessionManager, + customizationContext, actions: { initializeAndSync, fetchData, diff --git a/src/plugins/discover/public/application/types.ts b/src/plugins/discover/public/application/types.ts index 7e3413143ae516..70773d2db521fa 100644 --- a/src/plugins/discover/public/application/types.ts +++ b/src/plugins/discover/public/application/types.ts @@ -21,6 +21,17 @@ export enum FetchStatus { export type DiscoverDisplayMode = 'embedded' | 'standalone'; +export interface DiscoverCustomizationContext { + /* + * Display mode in which discover is running + */ + displayMode: DiscoverDisplayMode; + /** + * Whether or not to show the Log Explorer tabs + */ + showLogExplorerTabs: boolean; +} + export interface RecordsFetchResponse { records: DataTableRecord[]; textBasedQueryColumns?: DatatableColumn[]; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index da82df24e184f4..8c69c53e317e3d 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -53,6 +53,7 @@ import type { SettingsStart } from '@kbn/core-ui-settings-browser'; import type { ContentClient } from '@kbn/content-management-plugin/public'; import { memoize } from 'lodash'; import type { NoDataPagePluginStart } from '@kbn/no-data-page-plugin/public'; +import type { ServerlessPluginStart } from '@kbn/serverless/public'; import { getHistory } from './kibana_services'; import { DiscoverStartPlugins } from './plugin'; import { DiscoverContextAppLocator } from './application/context/services/locator'; @@ -111,6 +112,7 @@ export interface DiscoverServices { uiActions: UiActionsStart; contentClient: ContentClient; noDataPage?: NoDataPagePluginStart; + serverless?: ServerlessPluginStart; } export const buildServices = memoize(function ( @@ -171,5 +173,6 @@ export const buildServices = memoize(function ( uiActions: plugins.uiActions, contentClient: plugins.contentManagement.client, noDataPage: plugins.noDataPage, + serverless: plugins.serverless, }; }); 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 8c879fdfef7bc6..43426c83c77305 100644 --- a/src/plugins/discover/public/components/discover_container/discover_container.tsx +++ b/src/plugins/discover/public/components/discover_container/discover_container.tsx @@ -16,6 +16,7 @@ import type { DiscoverServices } from '../../build_services'; import type { CustomizationCallback } from '../../customizations'; import { setHeaderActionMenuMounter, setScopedHistory } from '../../kibana_services'; import { LoadingIndicator } from '../common/loading_indicator'; +import type { DiscoverCustomizationContext } from '../../application/types'; export interface DiscoverContainerInternalProps { /* @@ -43,6 +44,11 @@ const discoverContainerWrapperCss = css` } `; +const customizationContext: DiscoverCustomizationContext = { + displayMode: 'embedded', + showLogExplorerTabs: false, +}; + export const DiscoverContainerInternal = ({ overrideServices, scopedHistory, @@ -90,7 +96,7 @@ export const DiscoverContainerInternal = ({ diff --git a/src/plugins/discover/public/components/log_explorer_tabs/index.ts b/src/plugins/discover/public/components/log_explorer_tabs/index.ts new file mode 100644 index 00000000000000..b144b8fec104f0 --- /dev/null +++ b/src/plugins/discover/public/components/log_explorer_tabs/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { withSuspense } from '@kbn/shared-ux-utility'; +import { lazy } from 'react'; + +export type { LogExplorerTabsProps } from './log_explorer_tabs'; + +export const LogExplorerTabs = withSuspense(lazy(() => import('./log_explorer_tabs'))); diff --git a/src/plugins/discover/public/components/log_explorer_tabs/log_explorer_tabs.test.tsx b/src/plugins/discover/public/components/log_explorer_tabs/log_explorer_tabs.test.tsx new file mode 100644 index 00000000000000..5e4d295a7c89fa --- /dev/null +++ b/src/plugins/discover/public/components/log_explorer_tabs/log_explorer_tabs.test.tsx @@ -0,0 +1,88 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { discoverServiceMock } from '../../__mocks__/services'; +import { LogExplorerTabs, LogExplorerTabsProps } from './log_explorer_tabs'; +import { DISCOVER_APP_LOCATOR } from '../../../common'; +import { ALL_DATASETS_LOCATOR_ID } from '@kbn/deeplinks-observability'; + +const createMockLocator = (id: string) => ({ + navigate: jest.fn(), + getRedirectUrl: jest.fn().mockReturnValue(id), +}); + +describe('LogExplorerTabs', () => { + const renderTabs = (selectedTab: LogExplorerTabsProps['selectedTab'] = 'discover') => { + const mockDiscoverLocator = createMockLocator(DISCOVER_APP_LOCATOR); + const mockLogExplorerLocator = createMockLocator(ALL_DATASETS_LOCATOR_ID); + const services = { + ...discoverServiceMock, + share: { + ...discoverServiceMock.share, + url: { + ...discoverServiceMock.share?.url, + locators: { + get: jest.fn((id) => { + switch (id) { + case DISCOVER_APP_LOCATOR: + return mockDiscoverLocator; + case ALL_DATASETS_LOCATOR_ID: + return mockLogExplorerLocator; + default: + throw new Error(`Unknown locator id: ${id}`); + } + }), + }, + }, + }, + } as unknown as typeof discoverServiceMock; + + render(); + + return { + mockDiscoverLocator, + mockLogExplorerLocator, + }; + }; + + const getDiscoverTab = () => screen.getByText('Discover').closest('a')!; + const getLogExplorerTab = () => screen.getByText('Logs Explorer').closest('a')!; + + it('should render properly', () => { + const { mockDiscoverLocator, mockLogExplorerLocator } = renderTabs(); + expect(getDiscoverTab()).toBeInTheDocument(); + expect(mockDiscoverLocator.getRedirectUrl).toHaveBeenCalledWith({}); + expect(getDiscoverTab()).toHaveAttribute('href', DISCOVER_APP_LOCATOR); + expect(getLogExplorerTab()).toBeInTheDocument(); + expect(mockLogExplorerLocator.getRedirectUrl).toHaveBeenCalledWith({}); + expect(getLogExplorerTab()).toHaveAttribute('href', ALL_DATASETS_LOCATOR_ID); + }); + + it('should render Discover as the selected tab', () => { + const { mockDiscoverLocator, mockLogExplorerLocator } = renderTabs(); + expect(getDiscoverTab()).toHaveAttribute('aria-selected', 'true'); + userEvent.click(getDiscoverTab()); + expect(mockDiscoverLocator.navigate).not.toHaveBeenCalled(); + expect(getLogExplorerTab()).toHaveAttribute('aria-selected', 'false'); + userEvent.click(getLogExplorerTab()); + expect(mockLogExplorerLocator.navigate).toHaveBeenCalledWith({}); + }); + + it('should render Log Explorer as the selected tab', () => { + const { mockDiscoverLocator, mockLogExplorerLocator } = renderTabs('log-explorer'); + expect(getLogExplorerTab()).toHaveAttribute('aria-selected', 'true'); + userEvent.click(getLogExplorerTab()); + expect(mockLogExplorerLocator.navigate).not.toHaveBeenCalled(); + expect(getDiscoverTab()).toHaveAttribute('aria-selected', 'false'); + userEvent.click(getDiscoverTab()); + expect(mockDiscoverLocator.navigate).toHaveBeenCalledWith({}); + }); +}); diff --git a/src/plugins/discover/public/components/log_explorer_tabs/log_explorer_tabs.tsx b/src/plugins/discover/public/components/log_explorer_tabs/log_explorer_tabs.tsx new file mode 100644 index 00000000000000..ea9504d6b7ba1e --- /dev/null +++ b/src/plugins/discover/public/components/log_explorer_tabs/log_explorer_tabs.tsx @@ -0,0 +1,86 @@ +/* + * 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 { EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui'; +import { AllDatasetsLocatorParams, ALL_DATASETS_LOCATOR_ID } from '@kbn/deeplinks-observability'; +import { i18n } from '@kbn/i18n'; +import React, { MouseEvent } from 'react'; +import { DiscoverAppLocatorParams, DISCOVER_APP_LOCATOR } from '../../../common'; +import type { DiscoverServices } from '../../build_services'; + +export interface LogExplorerTabsProps { + services: Pick; + selectedTab: 'discover' | 'log-explorer'; +} + +const emptyParams = {}; + +export const LogExplorerTabs = ({ services, selectedTab }: LogExplorerTabsProps) => { + const { euiTheme } = useEuiTheme(); + const locators = services.share?.url.locators; + const discoverLocator = locators?.get(DISCOVER_APP_LOCATOR); + const logExplorerLocator = locators?.get(ALL_DATASETS_LOCATOR_ID); + const discoverUrl = discoverLocator?.getRedirectUrl(emptyParams); + const logExplorerUrl = logExplorerLocator?.getRedirectUrl(emptyParams); + + const navigateToDiscover = createNavigateHandler(() => { + if (selectedTab !== 'discover') { + discoverLocator?.navigate(emptyParams); + } + }); + + const navigateToLogExplorer = createNavigateHandler(() => { + if (selectedTab !== 'log-explorer') { + logExplorerLocator?.navigate(emptyParams); + } + }); + + return ( + + + {i18n.translate('discover.logExplorerTabs.discover', { + defaultMessage: 'Discover', + })} + + + {i18n.translate('discover.logExplorerTabs.logExplorer', { + defaultMessage: 'Logs Explorer', + })} + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default LogExplorerTabs; + +const isModifiedEvent = (event: MouseEvent) => + event.metaKey || event.altKey || event.ctrlKey || event.shiftKey; + +const isLeftClickEvent = (event: MouseEvent) => event.button === 0; + +const createNavigateHandler = (onClick: () => void) => (e: MouseEvent) => { + if (isModifiedEvent(e) || !isLeftClickEvent(e)) { + return; + } + + e.preventDefault(); + onClick(); +}; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index 37a46f30c4ccfb..e454798225d2f5 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -34,3 +34,4 @@ export type { } from './customizations'; export { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './embeddable'; export { loadSharingDataHelpers } from './utils'; +export { LogExplorerTabs, type LogExplorerTabsProps } from './components/log_explorer_tabs'; diff --git a/src/plugins/discover/public/mocks.tsx b/src/plugins/discover/public/mocks.tsx index e97c8f5a841a24..bef23a464a793c 100644 --- a/src/plugins/discover/public/mocks.tsx +++ b/src/plugins/discover/public/mocks.tsx @@ -17,6 +17,7 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { locator: sharePluginMock.createLocator(), + showLogExplorerTabs: jest.fn(), }; return setupContract; }; diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 88603a1a755d40..dc4e92e45d0bf4 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -46,6 +46,7 @@ import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import type { LensPublicStart } from '@kbn/lens-plugin/public'; import { TRUNCATE_MAX_HEIGHT, ENABLE_ESQL } from '@kbn/discover-utils'; import { NoDataPagePluginStart } from '@kbn/no-data-page-plugin/public'; +import type { ServerlessPluginStart } from '@kbn/serverless/public'; import { PLUGIN_ID } from '../common'; import { setHeaderActionMenuMounter, @@ -116,6 +117,7 @@ export interface DiscoverSetup { * ``` */ readonly locator: undefined | DiscoverAppLocator; + readonly showLogExplorerTabs: () => void; } export interface DiscoverStart { @@ -197,6 +199,7 @@ export interface DiscoverStartPlugins { lens: LensPublicStart; contentManagement: ContentManagementPublicStart; noDataPage?: NoDataPagePluginStart; + serverless?: ServerlessPluginStart; } /** @@ -214,6 +217,7 @@ export class DiscoverPlugin private locator?: DiscoverAppLocator; private contextLocator?: DiscoverContextAppLocator; private singleDocLocator?: DiscoverSingleDocLocator; + private showLogExplorerTabs = false; setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { const baseUrl = core.http.basePath.prepend('/app/discover'); @@ -318,18 +322,24 @@ export class DiscoverPlugin ); // make sure the data view list is up to date - await discoverStartPlugins.dataViews.clearCache(); + discoverStartPlugins.dataViews.clearCache(); - const { renderApp } = await import('./application'); // FIXME: Temporarily hide overflow-y in Discover app when Field Stats table is shown // due to EUI bug https://github.com/elastic/eui/pull/5152 params.element.classList.add('dscAppWrapper'); + + const { renderApp } = await import('./application'); const unmount = renderApp({ element: params.element, services, profileRegistry: this.profileRegistry, + customizationContext: { + displayMode: 'standalone', + showLogExplorerTabs: this.showLogExplorerTabs, + }, isDev, }); + return () => { unlistenParentHistory(); unmount(); @@ -368,6 +378,9 @@ export class DiscoverPlugin return { locator: this.locator, + showLogExplorerTabs: () => { + this.showLogExplorerTabs = true; + }, }; } diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index d4b09ed3938e3f..fe06c932324602 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -77,7 +77,9 @@ "@kbn/unsaved-changes-badge", "@kbn/rule-data-utils", "@kbn/core-chrome-browser", - "@kbn/core-plugins-server" + "@kbn/core-plugins-server", + "@kbn/serverless", + "@kbn/deeplinks-observability" ], "exclude": ["target/**/*"] } diff --git a/src/plugins/navigation/public/index.ts b/src/plugins/navigation/public/index.ts index 7db4526995c04c..65b8851a39ac20 100644 --- a/src/plugins/navigation/public/index.ts +++ b/src/plugins/navigation/public/index.ts @@ -14,7 +14,7 @@ export function plugin(initializerContext: PluginInitializerContext) { } export type { TopNavMenuData, TopNavMenuProps, TopNavMenuBadgeProps } from './top_nav_menu'; -export { TopNavMenu } from './top_nav_menu'; +export { TopNavMenu, TopNavMenuItems, TopNavMenuBadges } from './top_nav_menu'; export type { NavigationPublicPluginSetup, NavigationPublicPluginStart } from './types'; diff --git a/src/plugins/navigation/public/top_nav_menu/index.ts b/src/plugins/navigation/public/top_nav_menu/index.ts index e6705273c2a1b6..24986f3acb3baa 100644 --- a/src/plugins/navigation/public/top_nav_menu/index.ts +++ b/src/plugins/navigation/public/top_nav_menu/index.ts @@ -7,8 +7,10 @@ */ export { createTopNav } from './create_top_nav_menu'; -export type { TopNavMenuProps, TopNavMenuBadgeProps } from './top_nav_menu'; +export type { TopNavMenuProps } from './top_nav_menu'; export { TopNavMenu } from './top_nav_menu'; export type { TopNavMenuData } from './top_nav_menu_data'; export type { TopNavMenuExtensionsRegistrySetup } from './top_nav_menu_extensions_registry'; export { TopNavMenuExtensionsRegistry } from './top_nav_menu_extensions_registry'; +export { TopNavMenuItems } from './top_nav_menu_items'; +export { TopNavMenuBadges, type TopNavMenuBadgeProps } from './top_nav_menu_badges'; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 218c620519cf2c..376d0c4a7b442d 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -10,11 +10,12 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { MountPoint } from '@kbn/core/public'; -import { TopNavMenu, TopNavMenuBadgeProps } from './top_nav_menu'; +import { TopNavMenu } from './top_nav_menu'; import { TopNavMenuData } from './top_nav_menu_data'; -import { shallowWithIntl, mountWithIntl } from '@kbn/test-jest-helpers'; +import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { EuiToolTipProps } from '@elastic/eui'; +import type { TopNavMenuBadgeProps } from './top_nav_menu_badges'; const unifiedSearch = { ui: { @@ -66,35 +67,35 @@ describe('TopNavMenu', () => { ]; it('Should render nothing when no config is provided', () => { - const component = shallowWithIntl(); + const component = mountWithIntl(); expect(component.find(WRAPPER_SELECTOR).length).toBe(0); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); }); it('Should not render menu items when config is empty', () => { - const component = shallowWithIntl(); + const component = mountWithIntl(); expect(component.find(WRAPPER_SELECTOR).length).toBe(0); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); }); it('Should render 1 menu item', () => { - const component = shallowWithIntl(); + const component = mountWithIntl(); expect(component.find(WRAPPER_SELECTOR).length).toBe(1); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(1); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); }); it('Should render multiple menu items', () => { - const component = shallowWithIntl(); + const component = mountWithIntl(); expect(component.find(WRAPPER_SELECTOR).length).toBe(1); expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(menuItems.length); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); }); it('Should render search bar', () => { - const component = shallowWithIntl( + const component = mountWithIntl( ); expect(component.find(WRAPPER_SELECTOR).length).toBe(1); @@ -103,7 +104,7 @@ describe('TopNavMenu', () => { }); it('Should render menu items and search bar', () => { - const component = shallowWithIntl( + const component = mountWithIntl( { }); it('Should render with a class name', () => { - const component = shallowWithIntl( + const component = mountWithIntl( { className={'myCoolClass'} /> ); - expect(component.find('.kbnTopNavMenu').length).toBe(1); + expect(findTestSubject(component, 'top-nav').hasClass('kbnTopNavMenu')).toBe(true); expect(component.find('.myCoolClass').length).toBeTruthy(); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 9c899cd9d7207f..56daf31fb0703a 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -6,15 +6,7 @@ * Side Public License, v 1. */ -import React, { ReactElement, Fragment } from 'react'; -import { - EuiBadge, - EuiBadgeGroup, - EuiBadgeProps, - EuiHeaderLinks, - EuiToolTip, - EuiToolTipProps, -} from '@elastic/eui'; +import React, { ReactElement } from 'react'; import classNames from 'classnames'; import { MountPoint } from '@kbn/core/public'; @@ -23,13 +15,8 @@ import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/publi import { StatefulSearchBarProps } from '@kbn/unified-search-plugin/public'; import { AggregateQuery, Query } from '@kbn/es-query'; import { TopNavMenuData } from './top_nav_menu_data'; -import { TopNavMenuItem } from './top_nav_menu_item'; - -export type TopNavMenuBadgeProps = EuiBadgeProps & { - badgeText: string; - toolTipProps?: Partial; - renderCustomBadge?: (props: { badgeText: string }) => ReactElement; -}; +import { TopNavMenuItems } from './top_nav_menu_items'; +import { TopNavMenuBadgeProps, TopNavMenuBadges } from './top_nav_menu_badges'; export type TopNavMenuProps = Omit< StatefulSearchBarProps, @@ -83,54 +70,12 @@ export function TopNavMenu( return null; } - function createBadge( - { badgeText, toolTipProps, renderCustomBadge, ...badgeProps }: TopNavMenuBadgeProps, - i: number - ): ReactElement { - const key = `nav-menu-badge-${i}`; - - const Badge = () => ( - - {badgeText} - - ); - - if (renderCustomBadge) { - return {renderCustomBadge({ badgeText })}; - } - - return toolTipProps ? ( - - - - ) : ( - - ); - } - function renderBadges(): ReactElement | null { - if (!badges || badges.length === 0) return null; - return ( - - {badges.map(createBadge)} - - ); - } - - function renderItems(): ReactElement[] | null { - if (!config || config.length === 0) return null; - return config.map((menuItem: TopNavMenuData, i: number) => { - return ; - }); + return ; } function renderMenu(className: string): ReactElement | null { - if (!config || config.length === 0) return null; - return ( - - {renderItems()} - - ); + return ; } function renderSearchBar(): ReactElement | null { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_badges.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_badges.tsx new file mode 100644 index 00000000000000..f1c3a08e584180 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_badges.tsx @@ -0,0 +1,48 @@ +/* + * 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, EuiBadgeGroup, EuiToolTip, EuiBadgeProps, EuiToolTipProps } from '@elastic/eui'; +import React, { Fragment, ReactElement } from 'react'; + +export type TopNavMenuBadgeProps = EuiBadgeProps & { + badgeText: string; + toolTipProps?: Partial; + renderCustomBadge?: (props: { badgeText: string }) => ReactElement; +}; + +export const TopNavMenuBadges = ({ badges }: { badges: TopNavMenuBadgeProps[] | undefined }) => { + if (!badges || badges.length === 0) return null; + return ( + {badges.map(createBadge)} + ); +}; + +function createBadge( + { badgeText, toolTipProps, renderCustomBadge, ...badgeProps }: TopNavMenuBadgeProps, + i: number +): ReactElement { + const key = `nav-menu-badge-${i}`; + + const Badge = () => ( + + {badgeText} + + ); + + if (renderCustomBadge) { + return {renderCustomBadge({ badgeText })}; + } + + return toolTipProps ? ( + + + + ) : ( + + ); +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_items.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_items.tsx new file mode 100644 index 00000000000000..3113bdb4c1ce7b --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_items.tsx @@ -0,0 +1,29 @@ +/* + * 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 { EuiHeaderLinks } from '@elastic/eui'; +import React from 'react'; +import type { TopNavMenuData } from './top_nav_menu_data'; +import { TopNavMenuItem } from './top_nav_menu_item'; + +export const TopNavMenuItems = ({ + config, + className, +}: { + config: TopNavMenuData[] | undefined; + className?: string; +}) => { + if (!config || config.length === 0) return null; + return ( + + {config.map((menuItem: TopNavMenuData, i: number) => { + return ; + })} + + ); +}; diff --git a/x-pack/plugins/observability_log_explorer/common/translations.ts b/x-pack/plugins/observability_log_explorer/common/translations.ts index d04c9942c9cbcb..fb810311d134d0 100644 --- a/x-pack/plugins/observability_log_explorer/common/translations.ts +++ b/x-pack/plugins/observability_log_explorer/common/translations.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; export const logExplorerAppTitle = i18n.translate('xpack.observabilityLogExplorer.appTitle', { - defaultMessage: 'Log Explorer', + defaultMessage: 'Logs Explorer', }); export const logsAppTitle = i18n.translate('xpack.observabilityLogExplorer.logsAppTitle', { diff --git a/x-pack/plugins/observability_log_explorer/public/components/log_explorer_top_nav_menu.tsx b/x-pack/plugins/observability_log_explorer/public/components/log_explorer_top_nav_menu.tsx index 205fc824e409f1..c627f0760bb2d6 100644 --- a/x-pack/plugins/observability_log_explorer/public/components/log_explorer_top_nav_menu.tsx +++ b/x-pack/plugins/observability_log_explorer/public/components/log_explorer_top_nav_menu.tsx @@ -30,6 +30,7 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { css } from '@emotion/react'; import { LOG_EXPLORER_FEEDBACK_LINK } from '@kbn/observability-shared-plugin/common'; import { euiThemeVars } from '@kbn/ui-theme'; +import { LogExplorerTabs } from '@kbn/discover-plugin/public'; import { PluginKibanaContextValue } from '../utils/use_kibana'; import { betaBadgeDescription, @@ -73,12 +74,10 @@ const ServerlessTopNav = ({ state$, }: Pick) => { return ( - + - - - + - + + + + @@ -177,31 +179,7 @@ const StatefulTopNav = ({ const DiscoverLink = React.memo( ({ services, state$ }: Pick) => { - const { appState, logExplorerState } = useObservable( - state$.pipe( - distinctUntilChanged((prev, curr) => { - if (!prev.appState || !curr.appState) return false; - return deepEqual( - [ - prev.appState.columns, - prev.appState.filters, - prev.appState.index, - prev.appState.query, - ], - [curr.appState.columns, curr.appState.filters, curr.appState.index, curr.appState.query] - ); - }) - ), - { appState: {}, logExplorerState: {} } - ); - - const discoverLinkParams = { - columns: appState?.columns, - filters: appState?.filters, - query: appState?.query, - dataViewSpec: logExplorerState?.datasetSelection?.selection.dataset.toDataviewSpec(), - }; - + const discoverLinkParams = useDiscoverLinkParams(state$); const discoverUrl = services.discover.locator?.getRedirectUrl(discoverLinkParams); const navigateToDiscover = () => { @@ -217,7 +195,6 @@ const DiscoverLink = React.memo( {discoverLinkTitle} @@ -275,3 +252,38 @@ const VerticalRule = styled.span` height: 20px; background-color: ${euiThemeVars.euiColorLightShade}; `; + +const useDiscoverLinkParams = (state$: BehaviorSubject) => { + const { appState, logExplorerState } = useObservable( + state$.pipe( + distinctUntilChanged((prev, curr) => { + if (!prev.appState || !curr.appState) return false; + return deepEqual( + [ + prev.appState.columns, + prev.appState.sort, + prev.appState.filters, + prev.appState.index, + prev.appState.query, + ], + [ + curr.appState.columns, + curr.appState.sort, + curr.appState.filters, + curr.appState.index, + curr.appState.query, + ] + ); + }) + ), + { appState: {}, logExplorerState: {} } + ); + + return { + columns: appState?.columns, + sort: appState?.sort, + filters: appState?.filters, + query: appState?.query, + dataViewSpec: logExplorerState?.datasetSelection?.selection.dataset.toDataviewSpec(), + }; +}; diff --git a/x-pack/plugins/observability_log_explorer/public/plugin.ts b/x-pack/plugins/observability_log_explorer/public/plugin.ts index 755072ad9786d9..5b5a640e2a6aee 100644 --- a/x-pack/plugins/observability_log_explorer/public/plugin.ts +++ b/x-pack/plugins/observability_log_explorer/public/plugin.ts @@ -44,7 +44,7 @@ export class ObservabilityLogExplorerPlugin core: CoreSetup, _pluginsSetup: ObservabilityLogExplorerSetupDeps ) { - const { share } = _pluginsSetup; + const { share, serverless, discover } = _pluginsSetup; const useHash = core.uiSettings.get('state:storeInSessionStorage'); core.application.register({ @@ -69,6 +69,10 @@ export class ObservabilityLogExplorerPlugin }, }); + if (serverless) { + discover.showLogExplorerTabs(); + } + // Register Locators const singleDatasetLocator = share.url.locators.create( new SingleDatasetLocatorDefinition({ diff --git a/x-pack/plugins/observability_log_explorer/public/types.ts b/x-pack/plugins/observability_log_explorer/public/types.ts index 5f455088b7442c..245e8227c72b06 100644 --- a/x-pack/plugins/observability_log_explorer/public/types.ts +++ b/x-pack/plugins/observability_log_explorer/public/types.ts @@ -7,7 +7,7 @@ import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { LogExplorerPluginStart } from '@kbn/log-explorer-plugin/public'; -import { DiscoverStart } from '@kbn/discover-plugin/public'; +import { DiscoverSetup, DiscoverStart } from '@kbn/discover-plugin/public'; import { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public'; import { ServerlessPluginStart } from '@kbn/serverless/public'; import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; @@ -27,6 +27,7 @@ export interface ObservabilityLogExplorerPluginSetup { export interface ObservabilityLogExplorerPluginStart {} export interface ObservabilityLogExplorerSetupDeps { + discover: DiscoverSetup; serverless?: ServerlessPluginStart; share: SharePluginSetup; } diff --git a/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx b/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx index d53281dbf6de13..2cca4b3ea78928 100644 --- a/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx +++ b/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx @@ -29,15 +29,15 @@ const navigationTree: NavigationTreeDefinition = { breadcrumbStatus: 'hidden', children: [ { - title: i18n.translate('xpack.serverlessObservability.nav.logExplorer', { - defaultMessage: 'Log Explorer', + title: i18n.translate('xpack.serverlessObservability.nav.discover', { + defaultMessage: 'Discover', }), - link: 'observability-log-explorer', + link: 'discover', renderAs: 'item', children: [ { - // This is to show "discover" breadcrumbs when navigating from "log explorer" to "discover" - link: 'discover', + // This is to show "observability-log-explorer" breadcrumbs when navigating from "discover" to "log explorer" + link: 'observability-log-explorer', sideNavStatus: 'hidden', }, ], diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 00aa8b30278db0..488aa279b4c054 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -43365,7 +43365,6 @@ "xpack.serverlessObservability.nav.devTools": "Outils de développeur", "xpack.serverlessObservability.nav.getStarted": "Ajouter des données", "xpack.serverlessObservability.nav.infrastructure": "Infrastructure", - "xpack.serverlessObservability.nav.logExplorer": "Explorateur de log", "xpack.serverlessObservability.nav.ml.job.notifications": "Notifications de tâches", "xpack.serverlessObservability.nav.ml.jobs": "Détection des anomalies", "xpack.serverlessObservability.nav.mngt": "Gestion", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 36660d0392e6b2..c09f29875cb579 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -43356,7 +43356,6 @@ "xpack.serverlessObservability.nav.devTools": "開発者ツール", "xpack.serverlessObservability.nav.getStarted": "データの追加", "xpack.serverlessObservability.nav.infrastructure": "インフラストラクチャー", - "xpack.serverlessObservability.nav.logExplorer": "ログエクスプローラー", "xpack.serverlessObservability.nav.ml.job.notifications": "ジョブ通知", "xpack.serverlessObservability.nav.ml.jobs": "異常検知", "xpack.serverlessObservability.nav.mngt": "管理", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 84bf8c04333b53..6b809caf229bda 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -43349,7 +43349,6 @@ "xpack.serverlessObservability.nav.devTools": "开发者工具", "xpack.serverlessObservability.nav.getStarted": "添加数据", "xpack.serverlessObservability.nav.infrastructure": "基础设施", - "xpack.serverlessObservability.nav.logExplorer": "日志浏览器", "xpack.serverlessObservability.nav.ml.job.notifications": "作业通知", "xpack.serverlessObservability.nav.ml.jobs": "异常检测", "xpack.serverlessObservability.nav.mngt": "管理", diff --git a/x-pack/test/functional/apps/observability_log_explorer/app.ts b/x-pack/test/functional/apps/observability_log_explorer/app.ts index c60bf38205e3a7..5d6c85e3c75189 100644 --- a/x-pack/test/functional/apps/observability_log_explorer/app.ts +++ b/x-pack/test/functional/apps/observability_log_explorer/app.ts @@ -20,7 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.navigationalSearch.searchFor('log explorer'); const results = await PageObjects.navigationalSearch.getDisplayedResults(); - expect(results[0].label).to.eql('Log Explorer'); + expect(results[0].label).to.eql('Logs Explorer'); }); it('is shown in the observability side navigation', async () => { diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_url_state.ts b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_url_state.ts index 13629a6007937f..c0dc9c0dd89f1e 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_url_state.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_url_state.ts @@ -77,9 +77,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('Side nav', function () { - // Discover does not exist in Serverless O11y side nav (Log Explorer instead) - this.tags('skipSvlOblt'); - it('should sync Lens global state to Discover sidebar link and carry over the state when navigating to Discover', async () => { await PageObjects.common.navigateToApp('discover'); await PageObjects.common.navigateToApp('lens'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cypress/e2e/navigation.cy.ts b/x-pack/test_serverless/functional/test_suites/observability/cypress/e2e/navigation.cy.ts index b3d6c225f50c4e..8453042cc71617 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cypress/e2e/navigation.cy.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cypress/e2e/navigation.cy.ts @@ -13,7 +13,7 @@ describe.skip('Serverless', () => { it('contains the side navigation for observabilitity serverless', () => { cy.loginAsElasticUser(); - cy.contains('Log Explorer'); + cy.contains('Logs Explorer'); cy.contains('Dashboards'); cy.contains('Alerts'); cy.contains('AIOps'); @@ -26,7 +26,7 @@ describe.skip('Serverless', () => { it('navigates to discover-dashboard-viz links', () => { cy.loginAsElasticUser(); - cy.contains('Log Explorer').click(); + cy.contains('Logs Explorer').click(); cy.url().should('include', '/app/observability-log-explorer'); cy.contains('Dashboards').click(); diff --git a/x-pack/test_serverless/functional/test_suites/observability/navigation.ts b/x-pack/test_serverless/functional/test_suites/observability/navigation.ts index a295d93e009bc0..892359f901ffe5 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/navigation.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/navigation.ts @@ -41,15 +41,11 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { }); await svlCommonNavigation.sidenav.expectSectionClosed('project_settings_project_nav'); - // navigate to log explorer - await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'observability-log-explorer' }); - await svlCommonNavigation.sidenav.expectLinkActive({ - deepLinkId: 'observability-log-explorer', - }); - await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ - deepLinkId: 'observability-log-explorer', - }); - await expect(await browser.getCurrentUrl()).contain('/app/observability-log-explorer'); + // navigate to discover + await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'discover' }); + await svlCommonNavigation.sidenav.expectLinkActive({ deepLinkId: 'discover' }); + await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ deepLinkId: 'discover' }); + expect(await browser.getCurrentUrl()).contain('/app/discover'); // check the aiops subsection await svlCommonNavigation.sidenav.openSection('observability_project_nav.aiops'); // open ai ops subsection diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/app.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/app.ts index 271bba6b45035f..4a1cf5024ff4d4 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/app.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/app.ts @@ -34,7 +34,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.svlCommonNavigation.search.searchFor('log explorer'); const results = await PageObjects.svlCommonNavigation.search.getDisplayedResults(); - expect(results[0].label).to.eql('Log Explorer'); + expect(results[0].label).to.eql('Logs Explorer'); await PageObjects.svlCommonNavigation.search.hideSearch(); }); diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/header_menu.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/header_menu.ts index c9f8e598ff2a89..0e7527bc76887c 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/header_menu.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/header_menu.ts @@ -19,6 +19,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'svlCommonPage', 'timePicker', 'header', + 'svlCommonNavigation', ]); describe('Header menu', () => { @@ -27,6 +28,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.load( 'x-pack/test/functional/es_archives/observability_log_explorer/data_streams' ); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await PageObjects.svlCommonPage.login(); await PageObjects.observabilityLogExplorer.navigateTo(); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -38,6 +40,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.unload( 'x-pack/test/functional/es_archives/observability_log_explorer/data_streams' ); + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); }); it('should inject the app header menu on the top navbar', async () => { @@ -61,9 +64,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('superDatePickerstartDatePopoverButton'); await testSubjects.existOrFail('superDatePickerendDatePopoverButton'); }); - const timeConfig = await PageObjects.timePicker.getTimeConfig(); - // Set query bar value await PageObjects.observabilityLogExplorer.submitQuery('*favicon*'); @@ -75,7 +76,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await retry.try(async () => { expect(await PageObjects.discover.getCurrentlySelectedDataView()).to.eql('All logs'); }); - await retry.try(async () => { expect(await PageObjects.discover.getColumnHeaders()).to.eql([ '@timestamp', @@ -84,17 +84,80 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'message', ]); }); - await retry.try(async () => { expect(await PageObjects.timePicker.getTimeConfig()).to.eql(timeConfig); }); - await retry.try(async () => { expect(await PageObjects.observabilityLogExplorer.getQueryBarValue()).to.eql('*favicon*'); }); }); }); + describe('Discover tabs', () => { + before(async () => { + await PageObjects.observabilityLogExplorer.navigateTo(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('should navigate between discover tabs without keeping the current columns/filters/query/time/data view', async () => { + await retry.try(async () => { + await testSubjects.existOrFail('superDatePickerstartDatePopoverButton'); + await testSubjects.existOrFail('superDatePickerendDatePopoverButton'); + }); + + const timeConfig = await PageObjects.timePicker.getTimeConfig(); + + // Set query bar value + await PageObjects.observabilityLogExplorer.submitQuery('*favicon*'); + + // go to discover tab + await testSubjects.click('discoverTab'); + await PageObjects.svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ + deepLinkId: 'discover', + }); + await PageObjects.svlCommonNavigation.breadcrumbs.expectBreadcrumbMissing({ + deepLinkId: 'observability-log-explorer', + }); + expect(await browser.getCurrentUrl()).contain('/app/discover'); + + await PageObjects.discover.expandTimeRangeAsSuggestedInNoResultsMessage(); + await PageObjects.discover.waitForDocTableLoadingComplete(); + + await retry.try(async () => { + expect(await PageObjects.discover.getCurrentlySelectedDataView()).not.to.eql('All logs'); + }); + + await retry.try(async () => { + expect(await PageObjects.discover.getColumnHeaders()).not.to.eql([ + '@timestamp', + 'service.name', + 'host.name', + 'message', + ]); + }); + + await retry.try(async () => { + expect(await PageObjects.timePicker.getTimeConfig()).not.to.eql(timeConfig); + }); + + await retry.try(async () => { + expect(await PageObjects.observabilityLogExplorer.getQueryBarValue()).not.to.eql( + '*favicon*' + ); + }); + + // go to log explorer tab + await testSubjects.click('logExplorerTab'); + await PageObjects.svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ + deepLinkId: 'discover', + }); + await PageObjects.svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ + deepLinkId: 'observability-log-explorer', + }); + expect(await browser.getCurrentUrl()).contain('/app/observability-log-explorer'); + }); + }); + describe('Add data link', () => { before(async () => { await PageObjects.observabilityLogExplorer.navigateTo();