From cbb16b8a59e4c80b8979a7abf8d31c92d7c4935c Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 7 Dec 2023 20:28:52 -0400 Subject: [PATCH] [Discover] [Log Explorer] Add tabs to Discover and Log Explorer (#171467) ## Summary This PR adds tabs to navigate between Discover and Log Explorer in serverless O11y projects. The Discover top nav should remain unchanged in stateful deployments and all other serverless project types: ![tabs](https://github.com/elastic/kibana/assets/25592674/c68678be-ab1c-4323-bbd5-1f83828e8dce) > [!IMPORTANT] > While writing tests for this, I encountered a few issues we'll probably want to discuss and make decisions for before merging this PR. 1. When there are no data views in Kibana and a user navigates to Discover, they are prevented from accessing it and shown a no data screen. So if a user navigates to Discover in a serverless O11y project in order to get to Log Explorer but there are no existing data views, they will be unable to access the tabs since they will be blocked by the no data screen. Is this a realistic scenario in serverless O11y that we need to solve for, or will there always be at least one data view by default? 2. When navigating from the Log Explorer tab to the Discover tab, we convert the current dataset to an ad hoc data view to use in Discover. This doesn't work in reverse since Log Explorer can't load an arbitrary data view that might be selected in Discover, so instead I'm using the "all logs" locator. I'll leave it to the O11y team to decide if this is ok or if we need to take another approach here. 3. Since we are passing state between Discover and Log Explorer when a user switches between tabs, the default columns in Log Explorer will be overwritten by the current Discover grid columns (even if it's just the Document column). I imagine the O11y team doesn't want this, but how should we solve it? Should we avoid passing columns (and any other state that might overwrite defaults) when navigating from Discover to Log Explorer, or take another approach? ## Notes - The sidebar navigation link has been changed from "Log Explorer" to "Discover" and defaults to the Discover tab. - "Log Explorer" has been renamed to "Logs Explorer" in the UI. - The mockups used "Data Views" for the Discover tab title, but this was later decided against, so the implementation uses "Discover". - All of the same state that used to be passed from Log Explorer to Discover through the top nav button is still being passed, but I also added support for passing `sort` as well (I can revert this if it's unwanted). - In order to add tabs to the left side of the Discover top nav in serverless, we had to stop relying on Unified Search for rendering the top nav (in serverless only, stateful still uses it). Instead we are now rendering the top nav directly in Discover in serverless, similar to what Log Explorer does. This also required access to some of the internal navigation plugin components we rely on for the Discover top nav, so I exported them from the plugin since I figured it was better than duplicating them. Part of #169964. **In order to fully resolve this issue, there is still a remaining task to remove the "Data Views" tab from the Log Explorer dataset selector.** Part of #171386. **In order to fully resolve this issue, it looks like there may be some documentation work to do (especially since "Log Explorer" has been named to "Logs Explorer" in the UI).** ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/discover/kibana.jsonc | 3 +- .../public/__mocks__/discover_state.mock.ts | 4 + .../application/discover_router.test.tsx | 42 ++++- .../public/application/discover_router.tsx | 6 +- .../discover/public/application/index.tsx | 11 +- .../layout/__stories__/get_layout_props.ts | 9 +- .../components/layout/discover_layout.scss | 4 + .../components/layout/discover_layout.tsx | 13 +- .../top_nav/discover_topnav.test.tsx | 49 ++++- .../components/top_nav/discover_topnav.tsx | 70 ++----- .../discover_topnav_serverless.test.tsx | 177 ++++++++++++++++++ .../top_nav/discover_topnav_serverless.tsx | 53 ++++++ .../top_nav/get_top_nav_links.test.ts | 4 +- .../components/top_nav/get_top_nav_links.tsx | 10 +- .../top_nav/on_save_search.test.tsx | 4 + .../components/top_nav/use_discover_topnav.ts | 79 ++++++++ .../application/main/discover_main_app.tsx | 14 +- .../main/discover_main_route.test.tsx | 19 +- .../application/main/discover_main_route.tsx | 96 ++++++---- .../main/services/discover_state.test.ts | 32 +++- .../main/services/discover_state.ts | 23 ++- .../discover/public/application/types.ts | 11 ++ src/plugins/discover/public/build_services.ts | 3 + .../discover_container/discover_container.tsx | 8 +- .../components/log_explorer_tabs/index.ts | 14 ++ .../log_explorer_tabs.test.tsx | 88 +++++++++ .../log_explorer_tabs/log_explorer_tabs.tsx | 86 +++++++++ src/plugins/discover/public/index.ts | 1 + src/plugins/discover/public/mocks.tsx | 1 + src/plugins/discover/public/plugin.tsx | 17 +- src/plugins/discover/tsconfig.json | 4 +- src/plugins/navigation/public/index.ts | 2 +- .../navigation/public/top_nav_menu/index.ts | 4 +- .../public/top_nav_menu/top_nav_menu.test.tsx | 21 ++- .../public/top_nav_menu/top_nav_menu.tsx | 65 +------ .../top_nav_menu/top_nav_menu_badges.tsx | 48 +++++ .../top_nav_menu/top_nav_menu_items.tsx | 29 +++ .../common/translations.ts | 2 +- .../components/log_explorer_top_nav_menu.tsx | 74 +++++--- .../public/plugin.ts | 6 +- .../public/types.ts | 3 +- .../components/side_navigation/index.tsx | 10 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../apps/observability_log_explorer/app.ts | 2 +- .../common/discover/group1/_url_state.ts | 3 - .../cypress/e2e/navigation.cy.ts | 4 +- .../test_suites/observability/navigation.ts | 14 +- .../observability_log_explorer/app.ts | 2 +- .../observability_log_explorer/header_menu.ts | 73 +++++++- 51 files changed, 1035 insertions(+), 285 deletions(-) create mode 100644 src/plugins/discover/public/application/main/components/top_nav/discover_topnav_serverless.test.tsx create mode 100644 src/plugins/discover/public/application/main/components/top_nav/discover_topnav_serverless.tsx create mode 100644 src/plugins/discover/public/application/main/components/top_nav/use_discover_topnav.ts create mode 100644 src/plugins/discover/public/components/log_explorer_tabs/index.ts create mode 100644 src/plugins/discover/public/components/log_explorer_tabs/log_explorer_tabs.test.tsx create mode 100644 src/plugins/discover/public/components/log_explorer_tabs/log_explorer_tabs.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_menu_badges.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_menu_items.tsx 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();