From 4c2e0d021b2c872ec734606f0144ef3efaba211a Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Fri, 12 Dec 2025 13:20:56 +0100 Subject: [PATCH 01/76] Integrate new app menu with discover --- src/platform/plugins/shared/discover/moon.yml | 1 + .../main/components/tabs_view/index.ts | 2 +- .../main/components/tabs_view/tabs_view.tsx | 111 +++++++++++++++--- .../components/top_nav/discover_topnav.tsx | 4 +- .../top_nav/discover_topnav_menu.tsx | 58 ++------- .../application/main/discover_main_route.tsx | 63 ++++++---- .../plugins/shared/discover/tsconfig.json | 3 +- 7 files changed, 144 insertions(+), 98 deletions(-) diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index f247055a94e66..46e8ab005369b 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -127,6 +127,7 @@ dependsOn: - '@kbn/unified-metrics-grid' - '@kbn/shared-ux-link-redirect-app' - '@kbn/react-query' + - '@kbn/app-menu' tags: - plugin - prod diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/index.ts b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/index.ts index d6caf1dba08ed..b76d6a2710701 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { TabsView } from './tabs_view'; +export { TabsView, TabsBarWithAppMenu } from './tabs_view'; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx index 160e13f006c2f..053c5896ff4d0 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx @@ -7,8 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback } from 'react'; +import React, { useCallback, useContext } from 'react'; import { UnifiedTabs, type UnifiedTabsProps } from '@kbn/unified-tabs'; +import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { AppMenu } from '@kbn/app-menu'; +import useObservable from 'react-use/lib/useObservable'; import { SingleTabView, type SingleTabViewProps } from '../single_tab_view'; import { createTabItem, @@ -22,10 +26,25 @@ import { } from '../../state_management/redux'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { usePreviewData } from './use_preview_data'; +import { discoverTopNavMenuContext } from '../top_nav/discover_topnav_menu'; const MAX_TABS_COUNT = 25; -export const TabsView = (props: SingleTabViewProps) => { +const VerticalRule = () => { + const { euiTheme } = useEuiTheme(); + + return ( + + ); +}; + +export const TabsBarWithAppMenu = (props: SingleTabViewProps) => { const services = useDiscoverServices(); const dispatch = useInternalStateDispatch(); const items = useInternalStateSelector(selectAllTabs); @@ -34,6 +53,10 @@ export const TabsView = (props: SingleTabViewProps) => { const { getPreviewData } = usePreviewData(props.runtimeStateManager); const hideTabsBar = useInternalStateSelector(selectIsTabsBarHidden); const unsavedTabIds = useInternalStateSelector((state) => state.tabs.unsavedIds); + const { euiTheme } = useEuiTheme(); + + const { topNavMenu$ } = useContext(discoverTopNavMenuContext); + const topNavMenuItems = useObservable(topNavMenu$, topNavMenu$.getValue()); const scopedEbtManager = useCurrentTabRuntimeState( props.runtimeStateManager, @@ -62,26 +85,76 @@ export const TabsView = (props: SingleTabViewProps) => { [items] ); + if (hideTabsBar) { + return null; + } + + void topNavMenuItems; + + return ( +
+ + + + + + + + + {}, + order: 1, + id: 'placeholder', + iconType: 'gear', + }, + ], + }} + /> + + +
+ ); +}; + +export const TabsView = (props: SingleTabViewProps) => { + const items = useInternalStateSelector(selectAllTabs); + const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId); + const renderContent: UnifiedTabsProps['renderContent'] = useCallback( () => , [currentTabId, props] ); - return ( - - ); + return renderContent(items.find((item) => item.id === currentTabId) || items[0]); }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx index d08460fc57eed..d0397ec2e7466 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -197,7 +197,7 @@ export const DiscoverTopNav = ({ [dataView.id, dispatch, services, stateContainer] ); - const { topNavBadges, topNavMenu } = useDiscoverTopNav({ + const { topNavMenu } = useDiscoverTopNav({ stateContainer, persistedDiscoverSession, }); @@ -264,7 +264,7 @@ export const DiscoverTopNav = ({ return ( - + ({ topNavMenu$: new BehaviorSubject(undefined), - topNavBadges$: new BehaviorSubject(undefined), }); type DiscoverTopNavMenuContext = ReturnType; -const discoverTopNavMenuContext = createContext( +export const discoverTopNavMenuContext = createContext( createTopNavMenuContext() ); -// If there are no menu items to render yet, we render a placeholder -// item to ensure the menu still displays and to prevent flickering -const PLACEHOLDER_MENU_ITEMS: TopNavMenuData[] = [ - { - label: '', - run: () => {}, - className: css({ display: 'none' }), - }, -]; - export const DiscoverTopNavMenuProvider = ({ children }: PropsWithChildren) => { - const { setHeaderActionMenu } = useDiscoverServices(); const [topNavMenuContext] = useState(() => createTopNavMenuContext()); - const topNavMenu = useObservable( - topNavMenuContext.topNavMenu$, - topNavMenuContext.topNavMenu$.getValue() - ); - - const topNavBadges = useObservable( - topNavMenuContext.topNavBadges$, - topNavMenuContext.topNavBadges$.getValue() - ); - useUnmount(() => { - topNavMenuContext.topNavBadges$.next(undefined); topNavMenuContext.topNavMenu$.next(undefined); }); return ( - <> - 0 ? topNavMenu : PLACEHOLDER_MENU_ITEMS} - gutterSize="xxs" - setMenuMountPoint={setHeaderActionMenu} - /> - - {children} - - + + {children} + ); }; export const DiscoverTopNavMenu = ({ - topNavBadges, topNavMenu, -}: ReturnType) => { - const { topNavBadges$, topNavMenu$ } = useContext(discoverTopNavMenuContext); - - useLayoutEffect(() => { - topNavBadges$.next(topNavBadges); - }, [topNavBadges, topNavBadges$]); +}: Pick, 'topNavMenu'>) => { + const { topNavMenu$ } = useContext(discoverTopNavMenuContext); useLayoutEffect(() => { topNavMenu$.next(topNavMenu); diff --git a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx index b37be54aad8ba..855bf59cca3f7 100644 --- a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx @@ -31,12 +31,11 @@ import { useRootProfile, useDefaultAdHocDataViews } from '../../context_awarenes import type { SingleTabViewProps } from './components/single_tab_view'; import { BrandedLoadingIndicator, - SingleTabView, NoDataPage, InitializationError, } from './components/single_tab_view'; import { useAsyncFunction } from './hooks/use_async_function'; -import { TabsView } from './components/tabs_view'; +import { TabsView, TabsBarWithAppMenu } from './components/tabs_view'; import { ChartPortalsRenderer } from './components/chart'; import { useStateManagers } from './state_management/hooks/use_state_managers'; import { useUrl } from './hooks/use_url'; @@ -104,7 +103,6 @@ const DiscoverMainRouteContent = (props: SingleTabViewProps) => { const history = useHistory(); const dispatch = useInternalStateDispatch(); const rootProfileState = useRootProfile(); - const tabsEnabled = discoverFeatureFlags.getTabsEnabled(); const { initializeProfileDataViews } = useDefaultAdHocDataViews(); const [mainRouteInitializationState, initializeMainRoute] = useAsyncFunction( @@ -244,31 +242,48 @@ const DiscoverMainRouteContent = (props: SingleTabViewProps) => { ); } + const tabsEnabled = discoverFeatureFlags.getTabsEnabled(); + const shouldShowTabs = tabsEnabled && customizationContext.displayMode !== 'embedded'; + return ( - - <> -

- {persistedDiscoverSession?.title - ? i18n.translate('discover.pageTitleWithSavedSearch', { - defaultMessage: 'Discover - {savedSearchTitle}', - values: { - savedSearchTitle: persistedDiscoverSession.title, - }, - }) - : i18n.translate('discover.pageTitleWithoutSavedSearch', { - defaultMessage: 'Discover - Session not yet saved', - })} -

- {tabsEnabled && customizationContext.displayMode !== 'embedded' ? ( - - ) : ( - - )} - -
+
); }; + +interface DiscoverMainContentProps extends SingleTabViewProps { + shouldShowTabs: boolean; + persistedDiscoverSession: DiscoverInternalState['persistedDiscoverSession']; +} + +const DiscoverMainContent = ({ + shouldShowTabs, + persistedDiscoverSession, + ...props +}: DiscoverMainContentProps) => { + return ( + + {shouldShowTabs && } +

+ {persistedDiscoverSession?.title + ? i18n.translate('discover.pageTitleWithSavedSearch', { + defaultMessage: 'Discover - {savedSearchTitle}', + values: { + savedSearchTitle: persistedDiscoverSession.title, + }, + }) + : i18n.translate('discover.pageTitleWithoutSavedSearch', { + defaultMessage: 'Discover - Session not yet saved', + })} +

+ +
+ ); +}; diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index c10b010840395..b98ce6cd85db7 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -119,7 +119,8 @@ "@kbn/metrics-experience-plugin", "@kbn/unified-metrics-grid", "@kbn/shared-ux-link-redirect-app", - "@kbn/react-query" + "@kbn/react-query", + "@kbn/app-menu" ], "exclude": ["target/**/*"] } From fc5150164eb16ad8a1c4a1bbd77a9701beef5305 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 13 Jan 2026 10:33:45 +0100 Subject: [PATCH 02/76] Initial implementation --- .../kbn-managed-content-badge/index.ts | 4 +- .../get_top_nav_unsaved_changes_badge.tsx | 4 +- .../tabbed_content/tabbed_content.tsx | 102 ++++++++++++---- src/platform/plugins/shared/discover/moon.yml | 1 - .../main/components/tabs_view/tabs_view.tsx | 114 ++++-------------- .../top_nav/discover_topnav_menu.tsx | 4 +- .../components/top_nav/get_top_nav_badges.tsx | 11 +- .../top_nav/solutions_view_badge.tsx | 29 +++-- .../components/top_nav/use_discover_topnav.ts | 9 +- .../components/top_nav/use_top_nav_links.tsx | 109 ++++++++++++----- .../application/main/discover_main_route.tsx | 63 ++++------ .../plugins/shared/discover/tsconfig.json | 1 - 12 files changed, 242 insertions(+), 209 deletions(-) diff --git a/src/platform/packages/private/kbn-managed-content-badge/index.ts b/src/platform/packages/private/kbn-managed-content-badge/index.ts index e2af278b76ac5..d079524410c59 100644 --- a/src/platform/packages/private/kbn-managed-content-badge/index.ts +++ b/src/platform/packages/private/kbn-managed-content-badge/index.ts @@ -9,12 +9,12 @@ import { i18n } from '@kbn/i18n'; import type { EuiToolTipProps } from '@elastic/eui'; -import type { TopNavMenuBadgeProps } from '@kbn/navigation-plugin/public'; +import type { ChromeBreadcrumbsBadge } from '@kbn/core-chrome-browser'; export const getManagedContentBadge: ( tooltipText: string, disableTooltipProps?: boolean -) => TopNavMenuBadgeProps = (tooltipText, enableTooltipProps = true) => ({ +) => ChromeBreadcrumbsBadge = (tooltipText, enableTooltipProps = true) => ({ 'data-test-subj': 'managedContentBadge', badgeText: i18n.translate('managedContentBadge.text', { defaultMessage: 'Managed', diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx b/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx index 4524c58e4925f..899c6f05405ac 100644 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx +++ b/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import type { TopNavMenuBadgeProps } from '@kbn/navigation-plugin/public'; +import type { ChromeBreadcrumbsBadge } from '@kbn/core-chrome-browser'; import { UnsavedChangesBadge, type UnsavedChangesBadgeProps, @@ -34,7 +34,7 @@ export const getTopNavUnsavedChangesBadge = ({ onRevert, onSave, onSaveAs, -}: TopNavUnsavedChangesBadgeParams): TopNavMenuBadgeProps => { +}: TopNavUnsavedChangesBadgeParams): ChromeBreadcrumbsBadge => { return { badgeText: i18n.translate('unsavedChangesBadge.unsavedChangesTitle', { defaultMessage: 'Unsaved changes', diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx index fd8d0729a29c1..53ecb1f5e6d04 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx @@ -10,7 +10,8 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { escapeRegExp, omit, debounce } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; import { TabsBar, type TabsBarProps, type TabsBarApi } from '../tabs_bar'; import { getTabAttributes } from '../../utils/get_tab_attributes'; import { getTabMenuItemsFn } from '../../utils/get_tab_menu_items'; @@ -54,6 +55,7 @@ export interface TabbedContentProps getPreviewData?: (item: TabItem) => TabPreviewData; onEBTEvent: (event: TabsEBTEvent) => void; tabContentIdOverride?: string; + appendRight?: React.ReactNode; } export interface TabbedContentState { @@ -61,6 +63,20 @@ export interface TabbedContentState { selectedItem: TabItem | null; } +const VerticalRule = () => { + const { euiTheme } = useEuiTheme(); + + return ( + + ); +}; + export const TabbedContent: React.FC = ({ items: managedItems, selectedItemId: managedSelectedItemId, @@ -81,7 +97,9 @@ export const TabbedContent: React.FC = ({ disableInlineLabelEditing = false, disableDragAndDrop = false, disableTabsBarMenu = false, + appendRight, }) => { + const { euiTheme } = useEuiTheme(); const tabsBarApi = useRef(null); const [generatedId] = useState(() => tabContentIdOverride ?? htmlIdGenerator()()); const tabContentId = tabContentIdOverride ?? generatedId; @@ -322,32 +340,64 @@ export const TabbedContent: React.FC = ({ }); }, [state, maxItemsCount, onDuplicate, onCloseOtherTabs, onCloseTabsToTheRight]); + const tabsBarContainerCss = css` + background-color: ${euiTheme.colors.lightestShade}; + `; + + const tabsBarComponentCss = css` + min-width: 0; /* without this, TabsBar would push out AppMenu */ + `; + + const appendRightContainerCss = css` + margin-right: ${euiTheme.size.s}; + `; + const tabsBar = ( - + + + + + {appendRight ? ( + + + + + + {appendRight} + + + ) : null} + ); if (!renderContent) { diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index 5748d30b3fc58..ceac639f906c2 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -129,7 +129,6 @@ dependsOn: - '@kbn/controls-schemas' - '@kbn/zod' - '@kbn/cps' - - '@kbn/app-menu' tags: - plugin - prod diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx index 053c5896ff4d0..b0f40eddae00a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx @@ -9,11 +9,10 @@ import React, { useCallback, useContext } from 'react'; import { UnifiedTabs, type UnifiedTabsProps } from '@kbn/unified-tabs'; -import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; -import { AppMenu } from '@kbn/app-menu'; import useObservable from 'react-use/lib/useObservable'; +import { AppMenuComponent } from '@kbn/core-chrome-app-menu-components'; import { SingleTabView, type SingleTabViewProps } from '../single_tab_view'; +import { discoverTopNavMenuContext } from '../top_nav/discover_topnav_menu'; import { createTabItem, internalStateActions, @@ -26,25 +25,10 @@ import { } from '../../state_management/redux'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { usePreviewData } from './use_preview_data'; -import { discoverTopNavMenuContext } from '../top_nav/discover_topnav_menu'; const MAX_TABS_COUNT = 25; -const VerticalRule = () => { - const { euiTheme } = useEuiTheme(); - - return ( - - ); -}; - -export const TabsBarWithAppMenu = (props: SingleTabViewProps) => { +export const TabsView = (props: SingleTabViewProps) => { const services = useDiscoverServices(); const dispatch = useInternalStateDispatch(); const items = useInternalStateSelector(selectAllTabs); @@ -53,10 +37,6 @@ export const TabsBarWithAppMenu = (props: SingleTabViewProps) => { const { getPreviewData } = usePreviewData(props.runtimeStateManager); const hideTabsBar = useInternalStateSelector(selectIsTabsBarHidden); const unsavedTabIds = useInternalStateSelector((state) => state.tabs.unsavedIds); - const { euiTheme } = useEuiTheme(); - - const { topNavMenu$ } = useContext(discoverTopNavMenuContext); - const topNavMenuItems = useObservable(topNavMenu$, topNavMenu$.getValue()); const scopedEbtManager = useCurrentTabRuntimeState( props.runtimeStateManager, @@ -85,76 +65,30 @@ export const TabsBarWithAppMenu = (props: SingleTabViewProps) => { [items] ); - if (hideTabsBar) { - return null; - } - - void topNavMenuItems; - - return ( -
- - - - - - - - - {}, - order: 1, - id: 'placeholder', - iconType: 'gear', - }, - ], - }} - /> - - -
- ); -}; - -export const TabsView = (props: SingleTabViewProps) => { - const items = useInternalStateSelector(selectAllTabs); - const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId); - const renderContent: UnifiedTabsProps['renderContent'] = useCallback( () => , [currentTabId, props] ); - return renderContent(items.find((item) => item.id === currentTabId) || items[0]); + const { topNavMenu$ } = useContext(discoverTopNavMenuContext); + const topNavMenuItems = useObservable(topNavMenu$, topNavMenu$.getValue()); + + return ( + } + /> + ); }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx index 712fbab9f27a2..f89a37d8108a7 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx @@ -14,9 +14,9 @@ import React, { useState, useLayoutEffect, } from 'react'; -import { type TopNavMenuData } from '@kbn/navigation-plugin/public'; import { BehaviorSubject } from 'rxjs'; import useUnmount from 'react-use/lib/useUnmount'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import type { useDiscoverTopNav } from './use_discover_topnav'; /** @@ -27,7 +27,7 @@ import type { useDiscoverTopNav } from './use_discover_topnav'; */ const createTopNavMenuContext = () => ({ - topNavMenu$: new BehaviorSubject(undefined), + topNavMenu$: new BehaviorSubject(undefined), }); type DiscoverTopNavMenuContext = ReturnType; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx index 5bacc140b0023..c8caf540b5c8f 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx @@ -8,11 +8,11 @@ */ import React from 'react'; -import type { TopNavMenuBadgeProps } from '@kbn/navigation-plugin/public'; import { getTopNavUnsavedChangesBadge } from '@kbn/unsaved-changes-badge'; import { getManagedContentBadge } from '@kbn/managed-content-badge'; import { i18n } from '@kbn/i18n'; import { dismissFlyouts, DiscoverFlyouts } from '@kbn/discover-utils'; +import type { ChromeBreadcrumbsBadge } from '@kbn/core-chrome-browser'; import type { DiscoverStateContainer } from '../../state_management/discover_state'; import type { TopNavCustomization } from '../../../../customizations'; import type { DiscoverServices } from '../../../../build_services'; @@ -35,7 +35,7 @@ export const getTopNavBadges = ({ stateContainer: DiscoverStateContainer; services: DiscoverServices; topNavCustomization: TopNavCustomization | undefined; -}): TopNavMenuBadgeProps[] => { +}): ChromeBreadcrumbsBadge[] => { const saveDiscoverSession = (initialCopyOnSave?: boolean) => onSaveDiscoverSession({ initialCopyOnSave, @@ -44,16 +44,19 @@ export const getTopNavBadges = ({ }); const defaultBadges = topNavCustomization?.defaultBadges; - const entries: TopNavMenuBadgeProps[] = []; + const entries: ChromeBreadcrumbsBadge[] = []; const isManaged = stateContainer.savedSearchState.getState().managed; + // Show solutions view badge if spaces is enabled and not on mobile if (services.spaces && !isMobile) { entries.push({ badgeText: i18n.translate('discover.topNav.solutionViewTitle', { defaultMessage: 'Check out context-aware Discover', }), - renderCustomBadge: ({ badgeText }) => , + renderCustomBadge: ({ badgeText }) => ( + + ), }); } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.tsx index 7e6f666f4d60d..a69d9dcb40530 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.tsx @@ -13,10 +13,12 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import useObservable from 'react-use/lib/useObservable'; import { of } from 'rxjs'; -import { useDiscoverServices } from '../../../../hooks/use_discover_services'; +import type { DiscoverServices } from '../../../../build_services'; -export const SolutionsViewBadge: FunctionComponent<{ badgeText: string }> = ({ badgeText }) => { - const services = useDiscoverServices(); +export const SolutionsViewBadge: FunctionComponent<{ + badgeText: string; + services: DiscoverServices; +}> = ({ badgeText, services }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const activeSpace$ = useMemo( () => services.spaces?.getActiveSpace$() ?? of(undefined), @@ -25,15 +27,18 @@ export const SolutionsViewBadge: FunctionComponent<{ badgeText: string }> = ({ b const activeSpace = useObservable(activeSpace$); const canManageSpaces = services.capabilities.spaces?.manage === true; - // Do not render this component if one of the following conditions is met: - // 1. Solution visibility feature is disabled - // 2. Spaces is disabled (No active space available) - // 3. Active space is already configured to use a solution view other than "classic". - if ( - !services.spaces?.isSolutionViewEnabled || - !activeSpace || - (activeSpace.solution && activeSpace.solution !== 'classic') - ) { + // Don't render if solution view is disabled + if (!services.spaces?.isSolutionViewEnabled) { + return null; + } + + // Don't render if no active space data yet (loading) + if (!activeSpace) { + return null; + } + + // Don't render if already using a non-classic solution view + if (activeSpace.solution && activeSpace.solution !== 'classic') { return null; } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts index 30b598f3ccf64..d3e5287cc1618 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import { useIsWithinBreakpoints } from '@elastic/eui'; import { useDiscoverCustomization } from '../../../../customizations'; @@ -49,6 +49,13 @@ export const useDiscoverTopNav = ({ [stateContainer, services, hasUnsavedChanges, topNavCustomization, isMobile] ); + useEffect(() => { + services.chrome.setBreadcrumbsBadges(topNavBadges); + return () => { + services.chrome.setBreadcrumbsBadges([]); + }; + }, [topNavBadges, services.chrome]); + const unsavedTabIds = useInternalStateSelector((state) => state.tabs.unsavedIds); const currentTabId = useCurrentTabSelector((tab) => tab.id); const shouldShowESQLToDataViewTransitionModal = diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 40a6d7734382e..05031ee65833c 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -10,7 +10,6 @@ import { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import type { DataView } from '@kbn/data-views-plugin/public'; -import type { TopNavMenuData } from '@kbn/navigation-plugin/public'; import { METRIC_TYPE } from '@kbn/analytics'; import { ENABLE_ESQL, getInitialESQLQuery } from '@kbn/esql-utils'; import { @@ -24,6 +23,7 @@ import type { RuleTypeWithDescription } from '@kbn/alerts-ui-shared'; import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared'; import useObservable from 'react-use/lib/useObservable'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import { createDataViewDataSource } from '../../../../../common/data_sources'; import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; import type { DiscoverServices } from '../../../../build_services'; @@ -35,7 +35,6 @@ import { getOpenSearchAppMenuItem, getShareAppMenuItem, getInspectAppMenuItem, - convertAppMenuItemToTopNavItem, getBackgroundSearchFlyout, } from './app_menu_actions'; import type { TopNavCustomization } from '../../../../customizations'; @@ -78,7 +77,7 @@ export const useTopNavLinks = ({ shouldShowESQLToDataViewTransitionModal: boolean; hasShareIntegration: boolean; persistedDiscoverSession: DiscoverSession | undefined; -}): TopNavMenuData[] => { +}): AppMenuConfig => { const dispatch = useInternalStateDispatch(); const currentDataView = useCurrentDataView(); const appId = useObservable(services.application.currentAppId$); @@ -234,19 +233,75 @@ export const useTopNavLinks = ({ return getAppMenu(discoverParams).appMenuRegistry(newAppMenuRegistry); }, [getAppMenuAccessor, discoverParams, appMenuPrimaryAndSecondaryItems]); - return useMemo(() => { - const entries = appMenuRegistry.getSortedItems().map((appMenuItem) => - convertAppMenuItemToTopNavItem({ - appMenuItem, - services, - }) - ); + return useMemo((): AppMenuConfig => { + const items: AppMenuConfig['items'] = []; + let orderCounter = 100; + + // Map app menu registry items to AppMenuConfig items + appMenuRegistry.getSortedItems().forEach((appMenuItem) => { + if ('actions' in appMenuItem) { + // Submenu item - map to item with popover items + items.push({ + id: appMenuItem.id, + label: appMenuItem.label, + iconType: 'boxesHorizontal', // default icon for submenu + testId: appMenuItem.testId, + order: orderCounter++, + items: appMenuItem.actions + .filter((action) => action.type !== 'submenuHorizontalRule') + .map((action, index) => ({ + id: action.id, + label: 'controlProps' in action ? action.controlProps.label : '', + order: action.order ?? index * 100, + run: + 'controlProps' in action && action.controlProps.onClick + ? () => + action.controlProps.onClick?.({ + anchorElement: document.body, + onFinishAction: () => {}, + }) + : () => {}, + ...('controlProps' in action && action.controlProps.testId + ? { testId: action.controlProps.testId } + : {}), + ...('controlProps' in action && + 'iconType' in action.controlProps && + action.controlProps.iconType + ? { iconType: action.controlProps.iconType } + : {}), + })), + }); + } else { + // Simple item + const controlProps = appMenuItem.controlProps; + items.push({ + id: appMenuItem.id, + label: controlProps.label, + iconType: 'iconType' in controlProps ? controlProps.iconType : 'empty', + testId: controlProps.testId, + order: orderCounter++, + run: controlProps.onClick + ? () => + controlProps.onClick?.({ + anchorElement: document.body, + onFinishAction: () => {}, + }) + : () => {}, + ...(controlProps.href ? { href: controlProps.href } : {}), + ...(controlProps.tooltip ? { tooltipContent: controlProps.tooltip } : {}), + ...(controlProps.disableButton !== undefined + ? { disableButton: controlProps.disableButton } + : {}), + ...(controlProps.isLoading !== undefined ? { isLoading: controlProps.isLoading } : {}), + }); + } + }); if (services.uiSettings.get(ENABLE_ESQL)) { /** * Switches from ES|QL to classic mode and vice versa */ - const esqLDataViewTransitionToggle = { + items.unshift({ id: 'esql', label: isEsqlMode ? i18n.translate('discover.localMenu.switchToClassicTitle', { @@ -255,10 +310,10 @@ export const useTopNavLinks = ({ : i18n.translate('discover.localMenu.tryESQLTitle', { defaultMessage: 'Try ES|QL', }), - emphasize: true, - fill: false, - color: 'text', - tooltip: isEsqlMode + iconType: 'editorCodeBlock', + // fill: false, + // color: 'text', + tooltipContent: isEsqlMode ? i18n.translate('discover.localMenu.switchToClassicTooltipLabel', { defaultMessage: 'Switch to KQL or Lucene syntax.', }) @@ -289,36 +344,32 @@ export const useTopNavLinks = ({ } }, testId: isEsqlMode ? 'switch-to-dataviews' : 'select-text-based-language-btn', - }; - entries.unshift(esqLDataViewTransitionToggle); + order: 0, + }); } if (services.capabilities.discover_v2.save && !defaultMenu?.saveItem?.disabled) { - const saveSearch = { + items.push({ id: 'save', label: i18n.translate('discover.localMenu.saveTitle', { defaultMessage: 'Save', }), - description: i18n.translate('discover.localMenu.saveSearchDescription', { - defaultMessage: 'Save session', - }), + // description: i18n.translate('discover.localMenu.saveSearchDescription', { + // defaultMessage: 'Save session', + // }), testId: 'discoverSaveButton', iconType: 'save', - emphasize: true, - run: (anchorElement: HTMLElement) => { + run: () => { onSaveDiscoverSession({ services, state, - onClose: () => { - anchorElement?.focus(); - }, }); }, - }; - entries.push(saveSearch); + order: 1000, + }); } - return entries; + return { items }; }, [ appMenuRegistry, services, diff --git a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx index 855bf59cca3f7..b37be54aad8ba 100644 --- a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx @@ -31,11 +31,12 @@ import { useRootProfile, useDefaultAdHocDataViews } from '../../context_awarenes import type { SingleTabViewProps } from './components/single_tab_view'; import { BrandedLoadingIndicator, + SingleTabView, NoDataPage, InitializationError, } from './components/single_tab_view'; import { useAsyncFunction } from './hooks/use_async_function'; -import { TabsView, TabsBarWithAppMenu } from './components/tabs_view'; +import { TabsView } from './components/tabs_view'; import { ChartPortalsRenderer } from './components/chart'; import { useStateManagers } from './state_management/hooks/use_state_managers'; import { useUrl } from './hooks/use_url'; @@ -103,6 +104,7 @@ const DiscoverMainRouteContent = (props: SingleTabViewProps) => { const history = useHistory(); const dispatch = useInternalStateDispatch(); const rootProfileState = useRootProfile(); + const tabsEnabled = discoverFeatureFlags.getTabsEnabled(); const { initializeProfileDataViews } = useDefaultAdHocDataViews(); const [mainRouteInitializationState, initializeMainRoute] = useAsyncFunction( @@ -242,48 +244,31 @@ const DiscoverMainRouteContent = (props: SingleTabViewProps) => { ); } - const tabsEnabled = discoverFeatureFlags.getTabsEnabled(); - const shouldShowTabs = tabsEnabled && customizationContext.displayMode !== 'embedded'; - return ( - + + <> +

+ {persistedDiscoverSession?.title + ? i18n.translate('discover.pageTitleWithSavedSearch', { + defaultMessage: 'Discover - {savedSearchTitle}', + values: { + savedSearchTitle: persistedDiscoverSession.title, + }, + }) + : i18n.translate('discover.pageTitleWithoutSavedSearch', { + defaultMessage: 'Discover - Session not yet saved', + })} +

+ {tabsEnabled && customizationContext.displayMode !== 'embedded' ? ( + + ) : ( + + )} + +
); }; - -interface DiscoverMainContentProps extends SingleTabViewProps { - shouldShowTabs: boolean; - persistedDiscoverSession: DiscoverInternalState['persistedDiscoverSession']; -} - -const DiscoverMainContent = ({ - shouldShowTabs, - persistedDiscoverSession, - ...props -}: DiscoverMainContentProps) => { - return ( - - {shouldShowTabs && } -

- {persistedDiscoverSession?.title - ? i18n.translate('discover.pageTitleWithSavedSearch', { - defaultMessage: 'Discover - {savedSearchTitle}', - values: { - savedSearchTitle: persistedDiscoverSession.title, - }, - }) - : i18n.translate('discover.pageTitleWithoutSavedSearch', { - defaultMessage: 'Discover - Session not yet saved', - })} -

- -
- ); -}; diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index baf7e7bdf0650..9e4a0bb725dec 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -122,7 +122,6 @@ "@kbn/controls-schemas", "@kbn/zod", "@kbn/cps", - "@kbn/app-menu" ], "exclude": ["target/**/*"] } From 27259bbfb5599c7795cf7067e5916d5b27e9cba0 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Wed, 14 Jan 2026 00:40:26 +0100 Subject: [PATCH 03/76] Handle single tab mode --- .../components/top_nav/discover_topnav.tsx | 8 ++++++- .../top_nav/discover_topnav_menu.tsx | 11 +++++++++- .../top_nav/solutions_view_badge.tsx | 21 ++++++++----------- .../plugins/shared/discover/tsconfig.json | 2 +- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx index 449681b8f0adc..9d17bc41d5d43 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -91,6 +91,8 @@ export const DiscoverTopNav = ({ (state) => state.isESQLToDataViewTransitionModalVisible ); const tabsEnabled = services.discoverFeatureFlags.getTabsEnabled(); + const renderAppMenuOutsideTabs = + stateContainer.customizationContext.displayMode !== 'embedded' && !tabsEnabled; const persistedDiscoverSession = useInternalStateSelector( (state) => state.persistedDiscoverSession ); @@ -279,7 +281,11 @@ export const DiscoverTopNav = ({ return ( - + { export const DiscoverTopNavMenu = ({ topNavMenu, -}: Pick, 'topNavMenu'>) => { + renderAppMenuOutsideTabs, + setAppMenu, +}: Pick, 'topNavMenu'> & { + renderAppMenuOutsideTabs: boolean; + setAppMenu: (config?: AppMenuConfig) => void; +}) => { const { topNavMenu$ } = useContext(discoverTopNavMenuContext); useLayoutEffect(() => { topNavMenu$.next(topNavMenu); }, [topNavMenu, topNavMenu$]); + if (renderAppMenuOutsideTabs) { + return ; + } return null; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.tsx index a69d9dcb40530..5010a0443e0d8 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.tsx @@ -27,18 +27,15 @@ export const SolutionsViewBadge: FunctionComponent<{ const activeSpace = useObservable(activeSpace$); const canManageSpaces = services.capabilities.spaces?.manage === true; - // Don't render if solution view is disabled - if (!services.spaces?.isSolutionViewEnabled) { - return null; - } - - // Don't render if no active space data yet (loading) - if (!activeSpace) { - return null; - } - - // Don't render if already using a non-classic solution view - if (activeSpace.solution && activeSpace.solution !== 'classic') { + // Do not render this component if one of the following conditions is met: + // 1. Solution visibility feature is disabled + // 2. Spaces is disabled (No active space available) + // 3. Active space is already configured to use a solution view other than "classic". + if ( + !services.spaces?.isSolutionViewEnabled || + !activeSpace || + (activeSpace.solution && activeSpace.solution !== 'classic') + ) { return null; } diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 9e4a0bb725dec..298034cb4bb65 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -121,7 +121,7 @@ "@kbn/react-query", "@kbn/controls-schemas", "@kbn/zod", - "@kbn/cps", + "@kbn/cps" ], "exclude": ["target/**/*"] } From cc3fd5a46bccf463b91fb7e7092f5ddf4d5b8fb8 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:56:33 +0000 Subject: [PATCH 04/76] Changes from node scripts/lint_ts_projects --fix --- .../packages/private/kbn-managed-content-badge/tsconfig.json | 2 +- .../packages/private/kbn-unsaved-changes-badge/tsconfig.json | 2 +- src/platform/plugins/shared/discover/tsconfig.json | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/platform/packages/private/kbn-managed-content-badge/tsconfig.json b/src/platform/packages/private/kbn-managed-content-badge/tsconfig.json index 7270363aca5a4..05bb934988076 100644 --- a/src/platform/packages/private/kbn-managed-content-badge/tsconfig.json +++ b/src/platform/packages/private/kbn-managed-content-badge/tsconfig.json @@ -15,6 +15,6 @@ ], "kbn_references": [ "@kbn/i18n", - "@kbn/navigation-plugin", + "@kbn/core-chrome-browser", ] } diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/tsconfig.json b/src/platform/packages/private/kbn-unsaved-changes-badge/tsconfig.json index 2d4c5847f79cf..37fa6924386ef 100644 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/tsconfig.json +++ b/src/platform/packages/private/kbn-unsaved-changes-badge/tsconfig.json @@ -9,6 +9,6 @@ ], "kbn_references": [ "@kbn/i18n", - "@kbn/navigation-plugin", + "@kbn/core-chrome-browser", ] } diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 298034cb4bb65..58c06aac0c86d 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -121,7 +121,9 @@ "@kbn/react-query", "@kbn/controls-schemas", "@kbn/zod", - "@kbn/cps" + "@kbn/cps", + "@kbn/core-chrome-app-menu-components", + "@kbn/core-chrome-app-menu" ], "exclude": ["target/**/*"] } From 068b9c6616e89c4d92b78f2013f18d20acf60bdf Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 14 Jan 2026 00:09:40 +0000 Subject: [PATCH 05/76] Changes from node scripts/regenerate_moon_projects.js --update --- .../packages/private/kbn-managed-content-badge/moon.yml | 2 +- .../packages/private/kbn-unsaved-changes-badge/moon.yml | 2 +- src/platform/plugins/shared/discover/moon.yml | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/platform/packages/private/kbn-managed-content-badge/moon.yml b/src/platform/packages/private/kbn-managed-content-badge/moon.yml index e14bd12d9bccb..672210b2f3dad 100644 --- a/src/platform/packages/private/kbn-managed-content-badge/moon.yml +++ b/src/platform/packages/private/kbn-managed-content-badge/moon.yml @@ -19,7 +19,7 @@ project: sourceRoot: src/platform/packages/private/kbn-managed-content-badge dependsOn: - '@kbn/i18n' - - '@kbn/navigation-plugin' + - '@kbn/core-chrome-browser' tags: - shared-browser - package diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/moon.yml b/src/platform/packages/private/kbn-unsaved-changes-badge/moon.yml index 060601ecd4e1a..2fb779b6a4b39 100644 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/moon.yml +++ b/src/platform/packages/private/kbn-unsaved-changes-badge/moon.yml @@ -19,7 +19,7 @@ project: sourceRoot: src/platform/packages/private/kbn-unsaved-changes-badge dependsOn: - '@kbn/i18n' - - '@kbn/navigation-plugin' + - '@kbn/core-chrome-browser' tags: - shared-common - package diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index ceac639f906c2..94446c0359d7a 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -129,6 +129,8 @@ dependsOn: - '@kbn/controls-schemas' - '@kbn/zod' - '@kbn/cps' + - '@kbn/core-chrome-app-menu-components' + - '@kbn/core-chrome-app-menu' tags: - plugin - prod From 3e0c6f34e78b1dc16714f59cb73c0c8a3661b518 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 00:00:17 +0100 Subject: [PATCH 06/76] Map items --- .../components/app_menu/app_menu_registry.ts | 243 ++++-------- .../src/components/app_menu/types.ts | 1 - .../top_nav/app_menu_actions/get_alerts.tsx | 119 +++--- .../get_background_search_flyout.tsx | 21 +- .../top_nav/app_menu_actions/get_inspect.tsx | 27 +- .../app_menu_actions/get_new_search.tsx | 26 +- .../app_menu_actions/get_open_search.tsx | 27 +- .../top_nav/app_menu_actions/get_share.tsx | 46 +-- .../components/top_nav/get_top_nav_badges.tsx | 46 --- .../components/top_nav/use_discover_topnav.ts | 4 +- .../components/top_nav/use_top_nav_links.tsx | 373 +++++++++--------- .../accessors/get_app_menu.tsx | 204 +++++----- 12 files changed, 461 insertions(+), 676 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts index 85da2b6394150..5d8de8769be5e 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts @@ -8,209 +8,98 @@ */ import type { - AppMenuActionBase, - AppMenuActionSubmenuBase, - AppMenuActionSubmenuCustom, - AppMenuSubmenuHorizontalRule, - AppMenuActionSubmenuSecondary, - AppMenuItem, - AppMenuItemCustom, - AppMenuItemPrimary, - AppMenuItemSecondary, - AppMenuSubmenuActionCustom, -} from './types'; -import { AppMenuActionType } from './types'; + AppMenuConfig, + AppMenuItemType, + AppMenuPopoverItem, + AppMenuPrimaryActionItem, + AppMenuSecondaryActionItem, +} from '@kbn/core-chrome-app-menu-components'; +/** + * Registry for managing AppMenuConfig items. + * Works directly with AppMenuConfig types and allows registration of items, + * primary/secondary actions, and popover items for specific parent items. + */ export class AppMenuRegistry { - static CUSTOM_ITEMS_LIMIT = 2; + private items: Map = new Map(); + private primaryActionItem?: AppMenuPrimaryActionItem; + private secondaryActionItem?: AppMenuSecondaryActionItem; - private appMenuItems: AppMenuItem[]; /** - * As custom actions can be registered under a submenu from both root and data source profiles, we need to keep track of them separately. - * Otherwise, it would be less predictable. For example, we would override/reset the actions from the data source profile with the ones from the root profile. - * @internal + * Register a menu item. + * @param item The menu item to register (run accepts params) */ - private customSubmenuItemsBySubmenuId: Map< - string, - Array - >; - - constructor(primaryAndSecondaryActions: Array) { - this.appMenuItems = assignOrderToActions(primaryAndSecondaryActions); - this.customSubmenuItemsBySubmenuId = new Map(); + public registerItem(item: AppMenuItemType) { + this.items.set(item.id, item as AppMenuItemType); } - public isActionRegistered(appMenuItemId: string) { - return ( - this.appMenuItems.some((item) => { - if (item.id === appMenuItemId) { - return true; - } - if (isAppMenuActionSubmenu(item)) { - return item.actions.some((submenuItem) => submenuItem.id === appMenuItemId); - } - return false; - }) || - [...this.customSubmenuItemsBySubmenuId.values()].some((submenuItems) => - submenuItems.some((item) => item.id === appMenuItemId) - ) - ); + /** + * Register multiple menu items at once. + * @param items Array of menu items to register (run accepts params) + */ + public registerItems(items: AppMenuItemType[]) { + items.forEach((item) => this.registerItem(item)); } /** - * Register a custom action to the app menu. It can be a simple action or a submenu with more actions and horizontal rules. - * Note: Only 2 top level custom actions are allowed to be rendered in the app menu. The rest will be ignored. - * A custom action can also open a flyout or a modal. For that, return your custom react node from action's `onClick` event and call `onFinishAction` when you're done. - * @param appMenuItem + * Set the primary action item for the app menu. + * @param item The primary action item */ - public registerCustomAction(appMenuItem: AppMenuItemCustom) { - this.appMenuItems = [ - ...this.appMenuItems.filter( - // prevent duplicates - (item) => !(item.id === appMenuItem.id && item.type === AppMenuActionType.custom) - ), - appMenuItem, - ]; + public setPrimaryActionItem(item: AppMenuPrimaryActionItem) { + this.primaryActionItem = item; } /** - * Register a custom action under a submenu. It can be an action or a horizontal rule. - * Any number of submenu actions can be registered and rendered. - * You can also extend an existing submenu with more actions. For example, AppMenuActionType.alerts. - * `order` property is optional and can be used to control the order of actions in the submenu. - * @param submenuId - * @param appMenuItem + * Set the secondary action item for the app menu. + * @param item The secondary action item */ - public registerCustomActionUnderSubmenu( - submenuId: string, - appMenuItem: AppMenuSubmenuActionCustom | AppMenuSubmenuHorizontalRule - ) { - this.customSubmenuItemsBySubmenuId.set(submenuId, [ - ...(this.customSubmenuItemsBySubmenuId.get(submenuId) ?? []).filter( - // prevent duplicates and allow overrides - (item) => item.id !== appMenuItem.id - ), - appMenuItem, - ]); + public setSecondaryActionItem(item: AppMenuSecondaryActionItem) { + this.secondaryActionItem = item; } - private getSortedItemsForType(type: AppMenuActionType) { - let actions = this.appMenuItems.filter((item) => item.type === type); - - if (type === AppMenuActionType.custom && actions.length > AppMenuRegistry.CUSTOM_ITEMS_LIMIT) { - // apply the limitation on how many custom items can be shown - actions = actions.slice(0, AppMenuRegistry.CUSTOM_ITEMS_LIMIT); - } - - // enrich submenus with custom actions - if (type === AppMenuActionType.secondary || type === AppMenuActionType.custom) { - [...this.customSubmenuItemsBySubmenuId.entries()].forEach(([submenuId, customActions]) => { - actions = actions.map((item) => { - if (item.id === submenuId && isAppMenuActionSubmenu(item)) { - return extendSubmenuWithCustomActions(item, customActions); - } - return item; - }); - }); - } - - return sortAppMenuItemsByOrder(actions); + /** + * Register a popover item for a specific parent menu item. + * Run function will be wrapped to handle parameters internally. + * @param parentId The ID of the parent menu item + * @param popoverItem The popover item to register (run accepts params) + */ + public registerPopoverItem(parentId: string, popoverItem: AppMenuPopoverItem) { + this.items.set(parentId, { + ...this.items.get(parentId), + items: [...(this.items.get(parentId)?.items || []), popoverItem], + } as AppMenuItemType); } /** - * Get the resulting app menu items sorted by type and order. + * Register multiple popover items for a specific parent menu item. + * @param parentId The ID of the parent menu item + * @param popoverItems Array of popover items to register (run accepts params) */ - public getSortedItems() { - const primaryItems = this.getSortedItemsForType(AppMenuActionType.primary); - const secondaryItems = this.getSortedItemsForType(AppMenuActionType.secondary); - const customItems = this.getSortedItemsForType(AppMenuActionType.custom); - - return [...customItems, ...secondaryItems, ...primaryItems].filter( - (item) => !isAppMenuActionSubmenu(item) || item.actions.length > 0 - ); + public registerPopoverItems(parentId: string, popoverItems: AppMenuPopoverItem[]) { + popoverItems.forEach((item) => this.registerPopoverItem(parentId, item)); } -} - -function isAppMenuActionSubmenu( - appMenuItem: AppMenuItem -): appMenuItem is AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom { - return 'actions' in appMenuItem && Array.isArray(appMenuItem.actions); -} - -const FALLBACK_ORDER = Number.MAX_SAFE_INTEGER; - -function sortByOrder(a: T, b: T): number { - return (a.order ?? FALLBACK_ORDER) - (b.order ?? FALLBACK_ORDER); -} - -function getAppMenuSubmenuWithSortedItemsByOrder< - T extends AppMenuActionSubmenuBase = AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom ->(appMenuItem: T): T { - return { - ...appMenuItem, - actions: [...appMenuItem.actions].sort(sortByOrder), - }; -} -function sortAppMenuItemsByOrder(appMenuItems: AppMenuItem[]): AppMenuItem[] { - const sortedAppMenuItems = [...appMenuItems].sort(sortByOrder); - return sortedAppMenuItems.map((appMenuItem) => { - if (isAppMenuActionSubmenu(appMenuItem)) { - return getAppMenuSubmenuWithSortedItemsByOrder(appMenuItem); + /** + * Check if an item with the given ID is registered. + * @param itemId The ID to check + */ + public isItemRegistered(itemId: string): boolean { + if (this.items.has(itemId)) { + return true; } - return appMenuItem; - }); -} - -function getAppMenuSubmenuWithAssignedOrder< - T extends AppMenuActionSubmenuBase = AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom ->(appMenuItem: T, order: number): T { - let orderInSubmenu = 0; - const actionsWithOrder = appMenuItem.actions.map((action) => { - orderInSubmenu = orderInSubmenu + 100; - return { - ...action, - order: action.order ?? orderInSubmenu, - }; - }); - return { - ...appMenuItem, - order: appMenuItem.order ?? order, - actions: actionsWithOrder, - }; -} -function extendSubmenuWithCustomActions< - T extends AppMenuActionSubmenuBase = AppMenuActionSubmenuSecondary | AppMenuActionSubmenuCustom ->( - appMenuItem: T, - customActions: Array -): T { - const customActionsIds = new Set(customActions.map((action) => action.id)); - return { - ...appMenuItem, - actions: [ - ...appMenuItem.actions.filter((item) => !customActionsIds.has(item.id)), // allow to override secondary actions with custom ones - ...customActions, - ], - }; -} + return false; + } -/** - * All primary and secondary actions by default get order 100, 200, 300,... assigned to them. - * Same for actions under a submenu. - * @param appMenuItems - */ -function assignOrderToActions(appMenuItems: AppMenuItem[]): AppMenuItem[] { - let order = 0; - return appMenuItems.map((appMenuItem) => { - order = order + 100; - if (isAppMenuActionSubmenu(appMenuItem)) { - return getAppMenuSubmenuWithAssignedOrder(appMenuItem, order); - } + /** + * Get the complete AppMenuConfig. + * Items with registered popover items will have their items property populated. + */ + public getAppMenuConfig(): AppMenuConfig { return { - ...appMenuItem, - order: appMenuItem.order ?? order, + items: Array.from(this.items.values()), + primaryActionItem: this.primaryActionItem, + secondaryActionItem: this.secondaryActionItem, }; - }); + } } diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts index dfc281d7b8302..8d8527117d356 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts @@ -120,7 +120,6 @@ export interface AppMenuActionSubmenuBase { +}): AppMenuItemType => { const { dataView, isEsqlMode } = discoverParams; const timeField = getTimeField(dataView); const hasTimeFieldName = !isEsqlMode ? Boolean(dataView?.timeFieldName) : Boolean(timeField); + const items = []; + + if (services.capabilities.management?.insightsAndAlerting?.triggersActions) { + // activeSpace.solution && activeSpace.solution !== 'classic' TODO handle this + items.push({ + id: AppMenuActionId.manageRulesAndConnectors, + order: 2, + label: i18n.translate('discover.alerts.manageRulesAndConnectors', { + defaultMessage: 'Manage rules and connectors', + }), + iconType: 'tableOfContents', + testId: 'discoverManageAlertsButton', + href: services.application.getUrlForApp( + 'management/insightsAndAlerting/triggersActions/rules' + ), + }); + + if (discoverParams.authorizedRuleTypeIds.includes(ES_QUERY_ID)) { + items.push({ + id: AppMenuActionId.createRule, + order: 1, + label: i18n.translate('discover.alerts.createSearchThreshold', { + defaultMessage: 'Create search threshold rule', + }), + iconType: 'bell', + testId: 'discoverCreateAlertButton', + disableButton: !hasTimeFieldName, + tooltipContent: hasTimeFieldName + ? undefined + : i18n.translate('discover.alerts.missedTimeFieldToolTip', { + defaultMessage: 'Data view does not have a time field.', + }), + run: async () => { + // return ( + // + // ); + }, + }); + } + } + return { id: AppMenuActionId.alerts, - type: AppMenuActionType.secondary, label: i18n.translate('discover.localMenu.localMenu.alertsTitle', { defaultMessage: 'Alerts', }), - description: i18n.translate('discover.localMenu.alertsDescription', { - defaultMessage: 'Alerts', - }), testId: 'discoverAlertsButton', - actions: services.capabilities.management?.insightsAndAlerting?.triggersActions - ? [ - ...((discoverParams.authorizedRuleTypeIds.includes(ES_QUERY_ID) - ? [ - { - id: AppMenuActionId.createRule, - type: AppMenuActionType.secondary, - controlProps: { - label: i18n.translate('discover.alerts.createSearchThreshold', { - defaultMessage: 'Create search threshold rule', - }), - iconType: 'bell', - testId: 'discoverCreateAlertButton', - disableButton: !hasTimeFieldName, - tooltip: hasTimeFieldName - ? undefined - : i18n.translate('discover.alerts.missedTimeFieldToolTip', { - defaultMessage: 'Data view does not have a time field.', - }), - onClick: async (params) => { - return ( - - ); - }, - }, - }, - ] - : []) as AppMenuSubmenuActionSecondary[]), - { - id: 'alertsDivider', - type: AppMenuActionType.submenuHorizontalRule, - order: 109, - }, - { - id: AppMenuActionId.manageRulesAndConnectors, - type: AppMenuActionType.secondary, - order: 110, - controlProps: { - label: i18n.translate('discover.alerts.manageRulesAndConnectors', { - defaultMessage: 'Manage rules and connectors', - }), - iconType: 'tableOfContents', - testId: 'discoverManageAlertsButton', - href: services.application.getUrlForApp( - 'management/insightsAndAlerting/triggersActions/rules' - ), - onClick: undefined, - }, - }, - ] - : [], + order: 4, + iconType: 'alert', + popoverWidth: 250, + items, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_background_search_flyout.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_background_search_flyout.tsx index 6ca439ade8e74..bda6d2a2634c4 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_background_search_flyout.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_background_search_flyout.tsx @@ -7,24 +7,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { AppMenuActionId, AppMenuActionType, type AppMenuItemPrimary } from '@kbn/discover-utils'; +import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; +import { AppMenuActionId } from '@kbn/discover-utils'; import { i18n } from '@kbn/i18n'; export const getBackgroundSearchFlyout = ({ onClick, }: { onClick: () => void; -}): AppMenuItemPrimary => { +}): AppMenuItemType => { return { id: AppMenuActionId.backgroundsearch, - type: AppMenuActionType.primary, - controlProps: { - label: i18n.translate('discover.localMenu.localMenu.openBackgroundSearchFlyoutTitle', { - defaultMessage: 'Background searches', - }), - iconType: 'backgroundTask', - testId: 'openBackgroundSearchFlyoutButton', - onClick, - }, + order: 6, + label: i18n.translate('discover.localMenu.localMenu.openBackgroundSearchFlyoutTitle', { + defaultMessage: 'Background searches', + }), + iconType: 'backgroundTask', + testId: 'openBackgroundSearchFlyoutButton', + run: onClick, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx index 8d2a07b755ce8..83b48637a960a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx @@ -7,29 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AppMenuActionSecondary } from '@kbn/discover-utils'; -import { AppMenuActionId, AppMenuActionType } from '@kbn/discover-utils'; +import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; import { i18n } from '@kbn/i18n'; export const getInspectAppMenuItem = ({ onOpenInspector, }: { onOpenInspector: () => void; -}): AppMenuActionSecondary => { +}): AppMenuItemType => { return { - id: AppMenuActionId.inspect, - type: AppMenuActionType.secondary, - controlProps: { - label: i18n.translate('discover.localMenu.inspectTitle', { - defaultMessage: 'Inspect', - }), - description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', { - defaultMessage: 'Open Inspector for search', - }), - testId: 'openInspectorButton', - onClick: () => { - onOpenInspector(); - }, + id: 'inspect', + iconType: 'inspect', + order: 7, + label: i18n.translate('discover.localMenu.inspectTitle', { + defaultMessage: 'Inspect', + }), + testId: 'openInspectorButton', + run: () => { + onOpenInspector(); }, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx index 970e599f0e890..b9ec0d62cf8b1 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx @@ -7,8 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AppMenuActionPrimary } from '@kbn/discover-utils'; -import { AppMenuActionId, AppMenuActionType } from '@kbn/discover-utils'; +import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; +import { AppMenuActionId } from '@kbn/discover-utils'; import { i18n } from '@kbn/i18n'; export const getNewSearchAppMenuItem = ({ @@ -17,20 +17,18 @@ export const getNewSearchAppMenuItem = ({ }: { onNewSearch: () => void; newSearchUrl?: string; -}): AppMenuActionPrimary => { +}): AppMenuItemType => { return { id: AppMenuActionId.new, - type: AppMenuActionType.primary, - controlProps: { - label: i18n.translate('discover.localMenu.localMenu.newDiscoverSessionTitle', { - defaultMessage: 'New session', - }), - iconType: 'plus', - testId: 'discoverNewButton', - href: newSearchUrl, - onClick: () => { - onNewSearch(); - }, + order: 1, + label: i18n.translate('discover.localMenu.localMenu.newDiscoverSessionTitle', { + defaultMessage: 'New', + }), + iconType: 'plusInCircle', + testId: 'discoverNewButton', + href: newSearchUrl, + run: () => { + onNewSearch(); }, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx index fffb583b092c1..49d155d8c29b7 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx @@ -7,29 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; -import type { AppMenuActionPrimary } from '@kbn/discover-utils'; -import { AppMenuActionId, AppMenuActionType } from '@kbn/discover-utils'; +// import React from 'react'; +import { AppMenuActionId } from '@kbn/discover-utils'; import { i18n } from '@kbn/i18n'; -import { OpenSearchPanel } from '../open_search_panel'; +import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; export const getOpenSearchAppMenuItem = ({ onOpenSavedSearch, }: { onOpenSavedSearch: (savedSearchId: string) => void; -}): AppMenuActionPrimary => { +}): AppMenuItemType => { return { id: AppMenuActionId.open, - type: AppMenuActionType.primary, - controlProps: { - label: i18n.translate('discover.localMenu.openDiscoverSessionTitle', { - defaultMessage: 'Open session', - }), - iconType: 'folderOpen', - testId: 'discoverOpenButton', - onClick: ({ onFinishAction }) => { - return ; - }, + order: 2, + label: i18n.translate('discover.localMenu.openDiscoverSessionTitle', { + defaultMessage: 'Open', + }), + iconType: 'folderOpen', + testId: 'discoverOpenButton', + run: () => { + // return ; }, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 63a0da30fde14..147a37234899c 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -7,13 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AppMenuActionPrimary } from '@kbn/discover-utils'; -import { AppMenuActionId, AppMenuActionType } from '@kbn/discover-utils'; +import { AppMenuActionId } from '@kbn/discover-utils'; import { omit } from 'lodash'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { i18n } from '@kbn/i18n'; import type { TimeRange } from '@kbn/es-query'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; +import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; import type { DataTotalHitsMsg } from '../../../state_management/discover_data_state_container'; import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data'; @@ -40,7 +40,7 @@ export const getShareAppMenuItem = ({ currentTab: TabState; persistedDiscoverSession: DiscoverSession | undefined; totalHitsState: DataTotalHitsMsg; -}): AppMenuActionPrimary[] => { +}): AppMenuItemType[] => { if (!services.share) { return []; } @@ -161,20 +161,17 @@ export const getShareAppMenuItem = ({ }); }; - const menuItems: AppMenuActionPrimary[] = [ + const menuItems: AppMenuItemType[] = [ { id: AppMenuActionId.share, - type: AppMenuActionType.primary, - controlProps: { - label: i18n.translate('discover.localMenu.shareTitle', { - defaultMessage: 'Share', - }), - description: i18n.translate('discover.localMenu.shareSearchDescription', { - defaultMessage: 'Share Discover session', - }), - iconType: 'share', - testId: 'shareTopNavButton', - onClick: ({ anchorElement }) => shareExecutor({ anchorElement }), + order: 3, + label: i18n.translate('discover.localMenu.shareTitle', { + defaultMessage: 'Share', + }), + iconType: 'share', + testId: 'shareTopNavButton', + run: () => { + // shareExecutor({ anchorElement }); }, }, ]; @@ -182,17 +179,14 @@ export const getShareAppMenuItem = ({ if (hasIntegrations) { menuItems.unshift({ id: AppMenuActionId.export, - type: AppMenuActionType.primary, - controlProps: { - label: i18n.translate('discover.localMenu.exportTitle', { - defaultMessage: 'Export', - }), - description: i18n.translate('discover.localMenu.shareSearchDescription', { - defaultMessage: 'Export Discover session', - }), - iconType: 'download', - testId: 'exportTopNavButton', - onClick: ({ anchorElement }) => shareExecutor({ anchorElement, asExport: true }), + order: 8, + label: i18n.translate('discover.localMenu.exportTitle', { + defaultMessage: 'Export', + }), + iconType: 'download', + testId: 'exportTopNavButton', + run: () => { + // shareExecutor({ anchorElement, asExport: true }) }, }); } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx index c8caf540b5c8f..818b3f8c6656a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx @@ -8,42 +8,25 @@ */ import React from 'react'; -import { getTopNavUnsavedChangesBadge } from '@kbn/unsaved-changes-badge'; import { getManagedContentBadge } from '@kbn/managed-content-badge'; import { i18n } from '@kbn/i18n'; -import { dismissFlyouts, DiscoverFlyouts } from '@kbn/discover-utils'; import type { ChromeBreadcrumbsBadge } from '@kbn/core-chrome-browser'; import type { DiscoverStateContainer } from '../../state_management/discover_state'; -import type { TopNavCustomization } from '../../../../customizations'; import type { DiscoverServices } from '../../../../build_services'; import { SolutionsViewBadge } from './solutions_view_badge'; -import { onSaveDiscoverSession } from './save_discover_session'; -import { internalStateActions } from '../../state_management/redux'; /** * Helper function to build the top nav badges */ export const getTopNavBadges = ({ - hasUnsavedChanges, isMobile, stateContainer, services, - topNavCustomization, }: { - hasUnsavedChanges: boolean | undefined; isMobile: boolean; stateContainer: DiscoverStateContainer; services: DiscoverServices; - topNavCustomization: TopNavCustomization | undefined; }): ChromeBreadcrumbsBadge[] => { - const saveDiscoverSession = (initialCopyOnSave?: boolean) => - onSaveDiscoverSession({ - initialCopyOnSave, - services, - state: stateContainer, - }); - - const defaultBadges = topNavCustomization?.defaultBadges; const entries: ChromeBreadcrumbsBadge[] = []; const isManaged = stateContainer.savedSearchState.getState().managed; @@ -60,35 +43,6 @@ export const getTopNavBadges = ({ }); } - if (hasUnsavedChanges && !defaultBadges?.unsavedChangesBadge?.disabled) { - entries.push( - getTopNavUnsavedChangesBadge({ - onRevert: async () => { - dismissFlyouts([DiscoverFlyouts.lensEdit]); - - const { persistedDiscoverSession } = stateContainer.internalState.getState(); - - if (persistedDiscoverSession) { - await stateContainer.internalState - .dispatch(internalStateActions.resetDiscoverSession()) - .unwrap(); - } - }, - onSave: - services.capabilities.discover_v2.save && !isManaged - ? async () => { - await saveDiscoverSession(); - } - : undefined, - onSaveAs: services.capabilities.discover_v2.save - ? async () => { - await saveDiscoverSession(true); - } - : undefined, - }) - ); - } - if (isManaged) { entries.push( getManagedContentBadge( diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts index d3e5287cc1618..aff96bfb455e4 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts @@ -42,11 +42,9 @@ export const useDiscoverTopNav = ({ getTopNavBadges({ stateContainer, services, - hasUnsavedChanges, - topNavCustomization, isMobile, }), - [stateContainer, services, hasUnsavedChanges, topNavCustomization, isMobile] + [stateContainer, services, isMobile] ); useEffect(() => { diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 05031ee65833c..ddaf305d9826a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -12,18 +12,14 @@ import { i18n } from '@kbn/i18n'; import type { DataView } from '@kbn/data-views-plugin/public'; import { METRIC_TYPE } from '@kbn/analytics'; import { ENABLE_ESQL, getInitialESQLQuery } from '@kbn/esql-utils'; -import { - AppMenuRegistry, - type AppMenuItemPrimary, - type AppMenuItemSecondary, -} from '@kbn/discover-utils'; +import { AppMenuRegistry, dismissFlyouts, DiscoverFlyouts } from '@kbn/discover-utils'; import { ESQL_TYPE } from '@kbn/data-view-utils'; import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics'; import type { RuleTypeWithDescription } from '@kbn/alerts-ui-shared'; import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared'; import useObservable from 'react-use/lib/useObservable'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; -import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; +import type { AppMenuConfig, AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; import { createDataViewDataSource } from '../../../../../common/data_sources'; import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; import type { DiscoverServices } from '../../../../build_services'; @@ -115,193 +111,123 @@ export const useTopNavLinks = ({ const defaultMenu = topNavCustomization?.defaultMenu; - const appMenuPrimaryAndSecondaryItems: Array = - useMemo(() => { - const items: Array = []; - if (!defaultMenu?.inspectItem?.disabled) { - const inspectAppMenuItem = getInspectAppMenuItem({ onOpenInspector }); - items.push(inspectAppMenuItem); - } + const appMenuItems: AppMenuItemType[] = useMemo(() => { + const items: AppMenuItemType[] = []; + if (!defaultMenu?.inspectItem?.disabled) { + const inspectAppMenuItem = getInspectAppMenuItem({ onOpenInspector }); + items.push(inspectAppMenuItem); + } - if ( - services.triggersActionsUi && - !defaultMenu?.alertsItem?.disabled && - discoverParams.authorizedRuleTypeIds.length - ) { - const alertsAppMenuItem = getAlertsAppMenuItem({ - discoverParams, - services, - stateContainer: state, - }); - items.push(alertsAppMenuItem); - } + if ( + services.triggersActionsUi && + !defaultMenu?.alertsItem?.disabled && + discoverParams.authorizedRuleTypeIds.length + ) { + const alertsAppMenuItem = getAlertsAppMenuItem({ + discoverParams, + services, + stateContainer: state, + }); + items.push(alertsAppMenuItem); + } - if ( - !!appId && - services.data.search.isBackgroundSearchEnabled && - services.capabilities.discover_v2.storeSearchSession - ) { - const backgroundSearchFlyoutMenuItem = getBackgroundSearchFlyout({ - onClick: () => { - services.data.search.showSearchSessionsFlyout({ - appId, - trackingProps: { openedFrom: 'background search button' }, - onBackgroundSearchOpened: services.discoverFeatureFlags.getTabsEnabled() - ? ({ session, event }) => { - event?.preventDefault(); - dispatch( - internalStateActions.openSearchSessionInNewTab({ searchSession: session }) - ); - } - : undefined, - }); - }, - }); - items.push(backgroundSearchFlyoutMenuItem); - } + if ( + !!appId && + services.data.search.isBackgroundSearchEnabled && + services.capabilities.discover_v2.storeSearchSession + ) { + const backgroundSearchFlyoutMenuItem = getBackgroundSearchFlyout({ + onClick: () => { + services.data.search.showSearchSessionsFlyout({ + appId, + trackingProps: { openedFrom: 'background search button' }, + onBackgroundSearchOpened: services.discoverFeatureFlags.getTabsEnabled() + ? ({ session, event }) => { + event?.preventDefault(); + dispatch( + internalStateActions.openSearchSessionInNewTab({ searchSession: session }) + ); + } + : undefined, + }); + }, + }); + items.push(backgroundSearchFlyoutMenuItem); + } - if (!defaultMenu?.newItem?.disabled) { - const defaultEsqlState: Pick | undefined = - isEsqlMode && currentDataView.type === ESQL_TYPE - ? { query: { esql: getInitialESQLQuery(currentDataView, true) } } - : undefined; - const locatorParams: DiscoverAppLocatorParams = defaultEsqlState - ? defaultEsqlState - : currentDataView.isPersisted() - ? { dataViewId: currentDataView.id } - : { dataViewSpec: currentDataView.toMinimalSpec() }; - const newSearchMenuItem = getNewSearchAppMenuItem({ - newSearchUrl: services.locator.getRedirectUrl(locatorParams), - onNewSearch: () => { - const defaultState: DiscoverAppState = defaultEsqlState ?? { - dataSource: currentDataView.id - ? createDataViewDataSource({ dataViewId: currentDataView.id }) - : undefined, - }; - services.application.navigateToApp(DISCOVER_APP_ID, { state: { defaultState } }); - }, - }); - items.push(newSearchMenuItem); - } + if (!defaultMenu?.newItem?.disabled) { + const defaultEsqlState: Pick | undefined = + isEsqlMode && currentDataView.type === ESQL_TYPE + ? { query: { esql: getInitialESQLQuery(currentDataView, true) } } + : undefined; + const locatorParams: DiscoverAppLocatorParams = defaultEsqlState + ? defaultEsqlState + : currentDataView.isPersisted() + ? { dataViewId: currentDataView.id } + : { dataViewSpec: currentDataView.toMinimalSpec() }; + const newSearchMenuItem = getNewSearchAppMenuItem({ + newSearchUrl: services.locator.getRedirectUrl(locatorParams), + onNewSearch: () => { + const defaultState: DiscoverAppState = defaultEsqlState ?? { + dataSource: currentDataView.id + ? createDataViewDataSource({ dataViewId: currentDataView.id }) + : undefined, + }; + services.application.navigateToApp(DISCOVER_APP_ID, { state: { defaultState } }); + }, + }); + items.push(newSearchMenuItem); + } - if (!defaultMenu?.openItem?.disabled) { - const openSearchMenuItem = getOpenSearchAppMenuItem({ - onOpenSavedSearch: state.actions.onOpenSavedSearch, - }); - items.push(openSearchMenuItem); - } + if (!defaultMenu?.openItem?.disabled) { + const openSearchMenuItem = getOpenSearchAppMenuItem({ + onOpenSavedSearch: state.actions.onOpenSavedSearch, + }); + items.push(openSearchMenuItem); + } - if (!defaultMenu?.shareItem?.disabled) { - const shareAppMenuItem = getShareAppMenuItem({ - discoverParams, - services, - stateContainer: state, - hasIntegrations: hasShareIntegration, - hasUnsavedChanges, - currentTab, - persistedDiscoverSession, - totalHitsState, - }); - items.push(...shareAppMenuItem); - } + if (!defaultMenu?.shareItem?.disabled) { + const shareAppMenuItem = getShareAppMenuItem({ + discoverParams, + services, + stateContainer: state, + hasIntegrations: hasShareIntegration, + hasUnsavedChanges, + currentTab, + persistedDiscoverSession, + totalHitsState, + }); + items.push(...shareAppMenuItem); + } - return items; - }, [ - defaultMenu, - services, - discoverParams, - appId, - onOpenInspector, - state, - dispatch, - isEsqlMode, - currentDataView, - currentTab, - persistedDiscoverSession, - hasShareIntegration, - hasUnsavedChanges, - totalHitsState, - ]); + return items; + }, [ + defaultMenu, + services, + discoverParams, + appId, + onOpenInspector, + state, + dispatch, + isEsqlMode, + currentDataView, + currentTab, + persistedDiscoverSession, + hasShareIntegration, + hasUnsavedChanges, + totalHitsState, + ]); const getAppMenuAccessor = useProfileAccessor('getAppMenu'); const appMenuRegistry = useMemo(() => { - const newAppMenuRegistry = new AppMenuRegistry(appMenuPrimaryAndSecondaryItems); - const getAppMenu = getAppMenuAccessor(() => ({ - appMenuRegistry: () => newAppMenuRegistry, - })); + const newAppMenuRegistry = new AppMenuRegistry(); - return getAppMenu(discoverParams).appMenuRegistry(newAppMenuRegistry); - }, [getAppMenuAccessor, discoverParams, appMenuPrimaryAndSecondaryItems]); - - return useMemo((): AppMenuConfig => { - const items: AppMenuConfig['items'] = []; - let orderCounter = 100; - - // Map app menu registry items to AppMenuConfig items - appMenuRegistry.getSortedItems().forEach((appMenuItem) => { - if ('actions' in appMenuItem) { - // Submenu item - map to item with popover items - items.push({ - id: appMenuItem.id, - label: appMenuItem.label, - iconType: 'boxesHorizontal', // default icon for submenu - testId: appMenuItem.testId, - order: orderCounter++, - items: appMenuItem.actions - .filter((action) => action.type !== 'submenuHorizontalRule') - .map((action, index) => ({ - id: action.id, - label: 'controlProps' in action ? action.controlProps.label : '', - order: action.order ?? index * 100, - run: - 'controlProps' in action && action.controlProps.onClick - ? () => - action.controlProps.onClick?.({ - anchorElement: document.body, - onFinishAction: () => {}, - }) - : () => {}, - ...('controlProps' in action && action.controlProps.testId - ? { testId: action.controlProps.testId } - : {}), - ...('controlProps' in action && - 'iconType' in action.controlProps && - action.controlProps.iconType - ? { iconType: action.controlProps.iconType } - : {}), - })), - }); - } else { - // Simple item - const controlProps = appMenuItem.controlProps; - items.push({ - id: appMenuItem.id, - label: controlProps.label, - iconType: 'iconType' in controlProps ? controlProps.iconType : 'empty', - testId: controlProps.testId, - order: orderCounter++, - run: controlProps.onClick - ? () => - controlProps.onClick?.({ - anchorElement: document.body, - onFinishAction: () => {}, - }) - : () => {}, - ...(controlProps.href ? { href: controlProps.href } : {}), - ...(controlProps.tooltip ? { tooltipContent: controlProps.tooltip } : {}), - ...(controlProps.disableButton !== undefined - ? { disableButton: controlProps.disableButton } - : {}), - ...(controlProps.isLoading !== undefined ? { isLoading: controlProps.isLoading } : {}), - }); - } - }); + // Register all base items to the registry + newAppMenuRegistry.registerItems(appMenuItems); + // Add ESQL switch item if (services.uiSettings.get(ENABLE_ESQL)) { - /** - * Switches from ES|QL to classic mode and vice versa - */ - items.unshift({ + newAppMenuRegistry.registerItem({ id: 'esql', label: isEsqlMode ? i18n.translate('discover.localMenu.switchToClassicTitle', { @@ -311,8 +237,6 @@ export const useTopNavLinks = ({ defaultMessage: 'Try ES|QL', }), iconType: 'editorCodeBlock', - // fill: false, - // color: 'text', tooltipContent: isEsqlMode ? i18n.translate('discover.localMenu.switchToClassicTooltipLabel', { defaultMessage: 'Switch to KQL or Lucene syntax.', @@ -324,11 +248,6 @@ export const useTopNavLinks = ({ if (dataView) { if (isEsqlMode) { services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:back_to_classic_clicked`); - /** - * Display the transition modal if: - * - the user has not dismissed the modal - * - the user has opened and applied changes to the saved search - */ if ( shouldShowESQLToDataViewTransitionModal && !services.storage.get(ESQL_TRANSITION_MODAL_KEY) @@ -344,19 +263,16 @@ export const useTopNavLinks = ({ } }, testId: isEsqlMode ? 'switch-to-dataviews' : 'select-text-based-language-btn', - order: 0, + order: 9, }); } if (services.capabilities.discover_v2.save && !defaultMenu?.saveItem?.disabled) { - items.push({ + newAppMenuRegistry.setPrimaryActionItem({ id: 'save', label: i18n.translate('discover.localMenu.saveTitle', { defaultMessage: 'Save', }), - // description: i18n.translate('discover.localMenu.saveSearchDescription', { - // defaultMessage: 'Save session', - // }), testId: 'discoverSaveButton', iconType: 'save', run: () => { @@ -365,19 +281,82 @@ export const useTopNavLinks = ({ state, }); }, - order: 1000, + popoverWidth: 150, + splitButtonProps: { + showNotificationIndicator: hasUnsavedChanges, + notifcationIndicatorTooltipContent: hasUnsavedChanges + ? i18n.translate('discover.localMenu.unsavedChangesTooltip', { + defaultMessage: 'You have unsaved changes', + }) + : undefined, + secondaryButtonIcon: 'arrowDown', + secondaryButtonAriaLabel: i18n.translate('discover.localMenu.saveOptionsAriaLabel', { + defaultMessage: 'Save options', + }), + items: [ + { + run: async () => { + await onSaveDiscoverSession({ + initialCopyOnSave: true, + services, + state, + }); + }, + id: 'saveAs', + order: 1, + label: i18n.translate('discover.localMenu.saveAsTitle', { + defaultMessage: 'Save as', + }), + iconType: 'save', + testId: 'interactiveSaveMenuItem', + }, + { + run: async () => { + dismissFlyouts([DiscoverFlyouts.lensEdit]); + + const internalState = state.internalState.getState(); + + if (internalState.persistedDiscoverSession) { + await state.internalState + .dispatch(internalStateActions.resetDiscoverSession()) + .unwrap(); + } + }, + id: 'resetChanges', + order: 2, + label: i18n.translate('discover.localMenu.resetChangesTitle', { + defaultMessage: 'Reset changes', + }), + iconType: 'editorUndo', + testId: 'discardChangesMenuItem', + disableButton: !hasUnsavedChanges, + }, + ], + }, }); } - return { items }; + // Allow profile accessors to add additional items/popover items + const getAppMenu = getAppMenuAccessor(() => ({ + appMenuRegistry: () => newAppMenuRegistry, + })); + + return getAppMenu(discoverParams).appMenuRegistry(newAppMenuRegistry); }, [ - appMenuRegistry, + getAppMenuAccessor, + discoverParams, + appMenuItems, services, - defaultMenu?.saveItem?.disabled, isEsqlMode, dataView, shouldShowESQLToDataViewTransitionModal, dispatch, state, + defaultMenu?.saveItem?.disabled, + hasUnsavedChanges, ]); + + return useMemo((): AppMenuConfig => { + return appMenuRegistry.getAppMenuConfig(); + }, [appMenuRegistry]); }; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx index 015ebcfc3e02f..4fb197eaab574 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type { AppMenuRegistry } from '@kbn/discover-utils'; -import { AppMenuActionId, AppMenuActionType } from '@kbn/discover-utils'; +import { AppMenuActionId } from '@kbn/discover-utils'; import type { DataQualityLocatorParams } from '@kbn/deeplinks-observability'; import { DATA_QUALITY_LOCATOR_ID } from '@kbn/deeplinks-observability'; import { AlertConsumers, OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils'; @@ -48,30 +48,29 @@ const registerDatasetQualityLink = ( share?.url.locators.get(DATA_QUALITY_LOCATOR_ID); if (dataQualityLocator) { - registry.registerCustomAction({ + registry.registerItem({ id: 'dataset-quality-link', - type: AppMenuActionType.custom, - controlProps: { - label: i18n.translate('discover.observabilitySolution.appMenu.datasets', { - defaultMessage: 'Data sets', - }), - testId: 'discoverAppMenuDatasetQualityLink', - onClick: ({ onFinishAction }) => { - const refresh = timefilter.getRefreshInterval(); - const { from, to } = timefilter.getTime(); - - dataQualityLocator.navigate({ - filters: { - timeRange: { - from: from ?? 'now-24h', - to: to ?? 'now', - refresh, - }, + label: i18n.translate('discover.observabilitySolution.appMenu.datasets', { + defaultMessage: 'Data sets', + }), + order: 5, + iconType: 'database', + testId: 'discoverAppMenuDatasetQualityLink', + run: () => { + const refresh = timefilter.getRefreshInterval(); + const { from, to } = timefilter.getTime(); + + dataQualityLocator.navigate({ + filters: { + timeRange: { + from: from ?? 'now-24h', + to: to ?? 'now', + refresh, }, - }); + }, + }); - onFinishAction(); - }, + // onFinishAction(); }, }); } @@ -88,57 +87,55 @@ const registerCustomThresholdRuleAction = ( ) => { if (!authorizedRuleTypeIds.includes(OBSERVABILITY_THRESHOLD_RULE_TYPE_ID)) return; - registry.registerCustomActionUnderSubmenu(AppMenuActionId.alerts, { + registry.registerPopoverItem(AppMenuActionId.alerts, { id: 'custom-threshold-rule', - type: AppMenuActionType.custom, - order: 101, - controlProps: { - label: i18n.translate('discover.observabilitySolution.appMenu.customThresholdRule', { - defaultMessage: 'Create custom threshold rule', - }), - iconType: 'bell', - testId: 'discoverAppMenuCustomThresholdRule', - onClick: ({ onFinishAction }) => { - const index = dataView?.toMinimalSpec(); - const { filters, query } = data.query.getState(); - - // Some of the rule form's required plugins are from x-pack, so make sure they're defined before - // rendering the flyout. The alerting plugin is also part of x-pack, so this check should probably never - // return false. This is mostly here because Typescript requires us to mark x-pack plugins as optional. - const plugins = { ...services, data }; - if (!isValidRuleFormPlugins(plugins)) return null; - - return ( - { + const index = dataView?.toMinimalSpec(); + const { filters, query } = data.query.getState(); + + // Some of the rule form's required plugins are from x-pack, so make sure they're defined before + // rendering the flyout. The alerting plugin is also part of x-pack, so this check should probably never + // return false. This is mostly here because Typescript requires us to mark x-pack plugins as optional. + const plugins = { ...services, data }; + if (!isValidRuleFormPlugins(plugins)) return null; + + return ( + - ); - }, + }, + }} + // onSubmit={onFinishAction} + // onCancel={onFinishAction} + /> + ); }, }); }; @@ -152,42 +149,41 @@ const registerCreateSLOAction = ( const hasSloPermission = application.capabilities.slo?.write; if (sloFeature && hasSloPermission) { - registry.registerCustomActionUnderSubmenu(AppMenuActionId.alerts, { + registry.registerPopoverItem(AppMenuActionId.alerts, { id: 'create-slo', - type: AppMenuActionType.custom, - order: 102, - controlProps: { - label: i18n.translate('discover.observabilitySolution.appMenu.slo', { - defaultMessage: 'Create SLO', - }), - iconType: 'visGauge', - testId: 'discoverAppMenuCreateSlo', - onClick: ({ onFinishAction }) => { - const index = dataView?.getIndexPattern(); - const timestampField = dataView?.timeFieldName; - const { filters, query: kqlQuery } = data.query.getState(); - - const filter = isEsqlMode - ? {} - : { - kqlQuery: isOfQueryType(kqlQuery) ? kqlQuery.query : '', - filters: filters?.map(({ meta, query }) => ({ meta, query })), - }; - - return sloFeature.createSLOFlyout({ - initialValues: { - indicator: { - type: 'sli.kql.custom', - params: { - index, - timestampField, - filter, - }, + order: 3, + label: i18n.translate('discover.observabilitySolution.appMenu.slo', { + defaultMessage: 'Create SLO', + }), + iconType: 'visGauge', + testId: 'discoverAppMenuCreateSlo', + + run: () => { + const index = dataView?.getIndexPattern(); + const timestampField = dataView?.timeFieldName; + const { filters, query: kqlQuery } = data.query.getState(); + + const filter = isEsqlMode + ? {} + : { + kqlQuery: isOfQueryType(kqlQuery) ? kqlQuery.query : '', + filters: filters?.map(({ meta, query }) => ({ meta, query })), + }; + + // onFinishAction was here + return sloFeature.createSLOFlyout({ + initialValues: { + indicator: { + type: 'sli.kql.custom', + params: { + index, + timestampField, + filter, }, }, - onClose: onFinishAction, - }); - }, + }, + onClose: () => {}, + }); }, }); } From 491b7449aa62a329f88349561f64a21e5a7749c3 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 00:26:45 +0100 Subject: [PATCH 07/76] Handle share and export --- .../top_nav/app_menu_actions/get_share.tsx | 343 ++++++++++++------ .../components/top_nav/use_top_nav_links.tsx | 4 + 2 files changed, 234 insertions(+), 113 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 147a37234899c..27b68942c684b 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -13,7 +13,9 @@ import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { i18n } from '@kbn/i18n'; import type { TimeRange } from '@kbn/es-query'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; -import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; +import type { AppMenuItemType, AppMenuPopoverItem } from '@kbn/core-chrome-app-menu-components'; +import type { ShowShareMenuOptions } from '@kbn/share-plugin/public'; +import type { IntlShape } from '@kbn/i18n-react'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; import type { DataTotalHitsMsg } from '../../../state_management/discover_data_state_container'; import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data'; @@ -22,143 +24,247 @@ import type { AppMenuDiscoverParams } from './types'; import type { DiscoverServices } from '../../../../../build_services'; import type { TabState } from '../../../state_management/redux/types'; -export const getShareAppMenuItem = ({ +/** + * Builds share options for both share modal and export integrations + */ +const buildShareOptions = async ({ discoverParams, services, stateContainer, - hasIntegrations, - hasUnsavedChanges, currentTab, persistedDiscoverSession, totalHitsState, + hasUnsavedChanges, }: { discoverParams: AppMenuDiscoverParams; services: DiscoverServices; stateContainer: DiscoverStateContainer; - hasIntegrations: boolean; - hasUnsavedChanges: boolean; currentTab: TabState; persistedDiscoverSession: DiscoverSession | undefined; totalHitsState: DataTotalHitsMsg; -}): AppMenuItemType[] => { - if (!services.share) { - return []; - } + hasUnsavedChanges: boolean; +}): Promise> => { + const { dataView, isEsqlMode } = discoverParams; - const shareExecutor = async ({ - anchorElement, - asExport, - }: { - anchorElement: HTMLElement; - asExport?: boolean; - }) => { - const { dataView, isEsqlMode } = discoverParams; - - const searchSourceSharingData = await getSharingData( - stateContainer.savedSearchState.getState().searchSource, - currentTab.appState, - services, - isEsqlMode - ); + const searchSourceSharingData = await getSharingData( + stateContainer.savedSearchState.getState().searchSource, + currentTab.appState, + services, + isEsqlMode + ); - const { locator, discoverFeatureFlags } = services; - const { timefilter } = services.data.query.timefilter; - const timeRange = timefilter.getTime(); - const refreshInterval = timefilter.getRefreshInterval(); - const filters = services.filterManager.getFilters(); - - // Share -> Get links -> Snapshot - const params: DiscoverAppLocatorParams & { timeRange: TimeRange | undefined } = { - ...omit(currentTab.appState, 'dataSource'), - ...(persistedDiscoverSession?.id ? { savedSearchId: persistedDiscoverSession.id } : {}), - ...(dataView?.isPersisted() - ? { dataViewId: dataView?.id } - : { dataViewSpec: dataView?.toMinimalSpec() }), - filters, - timeRange: timeRange ?? undefined, - refreshInterval, - }; + const { locator, discoverFeatureFlags } = services; + const { timefilter } = services.data.query.timefilter; + const timeRange = timefilter.getTime(); + const refreshInterval = timefilter.getRefreshInterval(); + const filters = services.filterManager.getFilters(); - const tabsEnabled = discoverFeatureFlags.getTabsEnabled(); + // Share -> Get links -> Snapshot + const params: DiscoverAppLocatorParams & { timeRange: TimeRange | undefined } = { + ...omit(currentTab.appState, 'dataSource'), + ...(persistedDiscoverSession?.id ? { savedSearchId: persistedDiscoverSession.id } : {}), + ...(dataView?.isPersisted() + ? { dataViewId: dataView?.id } + : { dataViewSpec: dataView?.toMinimalSpec() }), + filters, + timeRange: timeRange ?? undefined, + refreshInterval, + }; - if (tabsEnabled && currentTab) { - params.tab = { - id: currentTab.id, - label: currentTab.label, - }; - } + const tabsEnabled = discoverFeatureFlags.getTabsEnabled(); - const relativeUrl = locator.getRedirectUrl(params); + if (tabsEnabled && currentTab) { + params.tab = { + id: currentTab.id, + label: currentTab.label, + }; + } - // This logic is duplicated from `relativeToAbsolute` (for bundle size reasons). Ultimately, this should be - // replaced when https://github.com/elastic/kibana/issues/153323 is implemented. - const link = document.createElement('a'); - link.setAttribute('href', relativeUrl); - const shareableUrl = link.href; + const relativeUrl = locator.getRedirectUrl(params); - // Share -> Get links -> Saved object - let shareableUrlForSavedObject = await locator.getUrl( - { savedSearchId: persistedDiscoverSession?.id }, - { absolute: true } - ); + // This logic is duplicated from `relativeToAbsolute` (for bundle size reasons). Ultimately, this should be + // replaced when https://github.com/elastic/kibana/issues/153323 is implemented. + const link = document.createElement('a'); + link.setAttribute('href', relativeUrl); + const shareableUrl = link.href; + + // Share -> Get links -> Saved object + let shareableUrlForSavedObject = await locator.getUrl( + { savedSearchId: persistedDiscoverSession?.id }, + { absolute: true } + ); + + // UrlPanelContent forces a '_g' parameter in the saved object URL: + // https://github.com/elastic/kibana/blob/a30508153c1467b1968fb94faf1debc5407f61ea/src/plugins/share/public/components/url_panel_content.tsx#L230 + // Since our locator doesn't add the '_g' parameter if it's not needed, UrlPanelContent + // will interpret it as undefined and add '?_g=' to the URL, which is invalid in Discover, + // so instead we add an empty object for the '_g' parameter to the URL. + shareableUrlForSavedObject = setStateToKbnUrl('_g', {}, undefined, shareableUrlForSavedObject); - // UrlPanelContent forces a '_g' parameter in the saved object URL: - // https://github.com/elastic/kibana/blob/a30508153c1467b1968fb94faf1debc5407f61ea/src/plugins/share/public/components/url_panel_content.tsx#L230 - // Since our locator doesn't add the '_g' parameter if it's not needed, UrlPanelContent - // will interpret it as undefined and add '?_g=' to the URL, which is invalid in Discover, - // so instead we add an empty object for the '_g' parameter to the URL. - shareableUrlForSavedObject = setStateToKbnUrl('_g', {}, undefined, shareableUrlForSavedObject); - - services.share?.toggleShareContextMenu({ - asExport, - anchorElement, - allowShortUrl: !!services.capabilities.discover_v2.createShortUrl, - shareableUrl, - shareableUrlForSavedObject, - shareableUrlLocatorParams: { locator, params }, - objectId: persistedDiscoverSession?.id, - objectType: 'search', - objectTypeAlias: i18n.translate('discover.share.objectTypeAlias', { - defaultMessage: 'Discover session', + return { + allowShortUrl: !!services.capabilities.discover_v2.createShortUrl, + shareableUrl, + shareableUrlForSavedObject, + shareableUrlLocatorParams: { locator, params }, + objectId: persistedDiscoverSession?.id, + objectType: 'search', + objectTypeAlias: i18n.translate('discover.share.objectTypeAlias', { + defaultMessage: 'Discover session', + }), + objectTypeMeta: { + title: i18n.translate('discover.share.shareModal.title', { + defaultMessage: 'Share this Discover session', }), - objectTypeMeta: { - title: i18n.translate('discover.share.shareModal.title', { - defaultMessage: 'Share this Discover session', - }), - config: { - embed: { - disabled: true, - showPublicUrlSwitch, - }, - integration: { - export: { - csvReports: { - draftModeCallOut: true, - }, + config: { + embed: { + disabled: true, + showPublicUrlSwitch, + }, + integration: { + export: { + csvReports: { + draftModeCallOut: true, }, }, - link: { - draftModeCallOut: tabsEnabled, - }, + }, + link: { + draftModeCallOut: tabsEnabled, }, }, - sharingData: { - isTextBased: isEsqlMode, - locatorParams: [{ id: locator.id, params }], - ...searchSourceSharingData, - // CSV reports can be generated without a saved search so we provide a fallback title - title: - persistedDiscoverSession?.title || - i18n.translate('discover.localMenu.fallbackReportTitle', { - defaultMessage: 'Untitled Discover session', + }, + sharingData: { + isTextBased: isEsqlMode, + locatorParams: [{ id: locator.id, params }], + ...searchSourceSharingData, + // CSV reports can be generated without a saved search so we provide a fallback title + title: + persistedDiscoverSession?.title || + i18n.translate('discover.localMenu.fallbackReportTitle', { + defaultMessage: 'Untitled Discover session', + }), + totalHits: totalHitsState.result || 0, + }, + isDirty: !persistedDiscoverSession?.id || hasUnsavedChanges, + }; +}; + +/** + * Generates export menu items from available share integrations + */ +const getExportItems = ( + buildShareOptionsParams: { + discoverParams: AppMenuDiscoverParams; + services: DiscoverServices; + stateContainer: DiscoverStateContainer; + currentTab: TabState; + persistedDiscoverSession: DiscoverSession | undefined; + totalHitsState: DataTotalHitsMsg; + hasUnsavedChanges: boolean; + }, + intl: IntlShape +): AppMenuPopoverItem[] => { + const { services } = buildShareOptionsParams; + + if (!services.share) return []; + + const exportIntegrations = services.share.availableIntegrations('search', 'export'); + const exportDerivatives = services.share.availableIntegrations('search', 'exportDerivatives'); + + const mapIntegrationToMetaData = (integrationId: string) => { + switch (integrationId) { + case 'csvReports': + return { + label: i18n.translate('discover.localMenu.export.csvLabel', { + defaultMessage: 'CSV', }), - totalHits: totalHitsState.result || 0, + testId: 'exportMenuItem-CSV', + iconType: 'tableDensityNormal' as const, + order: 1, + }; + case 'scheduledReports': + return { + label: i18n.translate('discover.localMenu.export.scheduleExportLabel', { + defaultMessage: 'Schedule export', + }), + testId: 'exportMenuItem-scheduledReports', + iconType: 'calendar' as const, + order: 2, + }; + default: + return { + label: integrationId, + testId: `exportMenuItem-${integrationId}`, + order: Number.MAX_SAFE_INTEGER, + }; + } + }; + + const exportItems = exportIntegrations + .filter((item) => item.shareType === 'integration') + .map((item) => ({ + ...mapIntegrationToMetaData(item.id), + id: item.id, + run: async () => { + const shareOptions = await buildShareOptions(buildShareOptionsParams); + const handler = await services.share?.getExportHandler(shareOptions, item.id, intl); + await handler?.(); }, - isDirty: !persistedDiscoverSession?.id || hasUnsavedChanges, - onClose: () => { - anchorElement?.focus(); + })); + + const derivativeItems = exportDerivatives + .filter( + (item): item is typeof item & { shareType: 'integration'; id: string } => + item.shareType === 'integration' && item.groupId === 'exportDerivatives' + ) + .map((item) => ({ + ...mapIntegrationToMetaData(item.id), + id: item.id, + run: async () => { + const shareOptions = await buildShareOptions(buildShareOptionsParams); + const handler = await services.share?.getExportDerivativeHandler(shareOptions, item.id); + await handler?.(); }, + })); + + return [...exportItems, ...derivativeItems]; +}; + +export const getShareAppMenuItem = ({ + discoverParams, + services, + stateContainer, + hasIntegrations, + hasUnsavedChanges, + currentTab, + persistedDiscoverSession, + totalHitsState, + intl, +}: { + discoverParams: AppMenuDiscoverParams; + services: DiscoverServices; + stateContainer: DiscoverStateContainer; + hasIntegrations: boolean; + hasUnsavedChanges: boolean; + currentTab: TabState; + persistedDiscoverSession: DiscoverSession | undefined; + totalHitsState: DataTotalHitsMsg; + intl: IntlShape; +}): AppMenuItemType[] => { + if (!services.share) { + return []; + } + + const shareExecutor = async () => { + const shareOptions = await buildShareOptions({ + discoverParams, + services, + stateContainer, + currentTab, + persistedDiscoverSession, + totalHitsState, + hasUnsavedChanges, }); + services.share?.toggleShareContextMenu(shareOptions); }; const menuItems: AppMenuItemType[] = [ @@ -171,23 +277,34 @@ export const getShareAppMenuItem = ({ iconType: 'share', testId: 'shareTopNavButton', run: () => { - // shareExecutor({ anchorElement }); + shareExecutor(); }, }, ]; if (hasIntegrations) { + const exportItems = getExportItems( + { + discoverParams, + services, + stateContainer, + currentTab, + persistedDiscoverSession, + totalHitsState, + hasUnsavedChanges, + }, + intl + ); + menuItems.unshift({ id: AppMenuActionId.export, order: 8, label: i18n.translate('discover.localMenu.exportTitle', { defaultMessage: 'Export', }), - iconType: 'download', + iconType: 'exportAction', testId: 'exportTopNavButton', - run: () => { - // shareExecutor({ anchorElement, asExport: true }) - }, + items: exportItems, }); } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index ddaf305d9826a..e148fc9aaeb53 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -20,6 +20,7 @@ import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared'; import useObservable from 'react-use/lib/useObservable'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import type { AppMenuConfig, AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; +import { useI18n } from '@kbn/i18n-react'; import { createDataViewDataSource } from '../../../../../common/data_sources'; import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; import type { DiscoverServices } from '../../../../build_services'; @@ -74,6 +75,7 @@ export const useTopNavLinks = ({ hasShareIntegration: boolean; persistedDiscoverSession: DiscoverSession | undefined; }): AppMenuConfig => { + const intl = useI18n(); const dispatch = useInternalStateDispatch(); const currentDataView = useCurrentDataView(); const appId = useObservable(services.application.currentAppId$); @@ -196,6 +198,7 @@ export const useTopNavLinks = ({ currentTab, persistedDiscoverSession, totalHitsState, + intl, }); items.push(...shareAppMenuItem); } @@ -216,6 +219,7 @@ export const useTopNavLinks = ({ hasShareIntegration, hasUnsavedChanges, totalHitsState, + intl, ]); const getAppMenuAccessor = useProfileAccessor('getAppMenu'); From 821810e50ccf524b5ac66fdd8700d8dd3498b617 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 00:46:27 +0100 Subject: [PATCH 08/76] Handle open --- .../app_menu_actions/get_open_search.tsx | 23 +++++++++++++++++-- .../components/top_nav/use_top_nav_links.tsx | 1 + 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx index 49d155d8c29b7..e6e40691aad22 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx @@ -7,14 +7,20 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -// import React from 'react'; +import React from 'react'; import { AppMenuActionId } from '@kbn/discover-utils'; import { i18n } from '@kbn/i18n'; import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { DiscoverServices } from '../../../../../build_services'; +import { OpenSearchPanel } from '../open_search_panel'; export const getOpenSearchAppMenuItem = ({ + services, onOpenSavedSearch, }: { + services: DiscoverServices; onOpenSavedSearch: (savedSearchId: string) => void; }): AppMenuItemType => { return { @@ -26,7 +32,20 @@ export const getOpenSearchAppMenuItem = ({ iconType: 'folderOpen', testId: 'discoverOpenButton', run: () => { - // return ; + const overlay = services.core.overlays.openFlyout( + toMountPoint( + + overlay.close()} + onOpenSavedSearch={(savedSearchId) => { + overlay.close(); + onOpenSavedSearch(savedSearchId); + }} + /> + , + services.core + ) + ); }, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index e148fc9aaeb53..d62d5b44555fd 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -183,6 +183,7 @@ export const useTopNavLinks = ({ if (!defaultMenu?.openItem?.disabled) { const openSearchMenuItem = getOpenSearchAppMenuItem({ + services, onOpenSavedSearch: state.actions.onOpenSavedSearch, }); items.push(openSearchMenuItem); From 556f3bc0ad8b094e0c0f07a3a6cb98998ee859f6 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 00:48:30 +0100 Subject: [PATCH 09/76] Handle alerts --- .../components/app_menu/app_menu_registry.ts | 13 +- .../src/components/app_menu/types.ts | 135 ------------ .../rule_form/flyout/rule_form_flyout.tsx | 58 +++++- .../convert_to_top_nav_item.test.ts | 120 ----------- .../convert_to_top_nav_item.ts | 56 ----- .../top_nav/app_menu_actions/get_alerts.tsx | 31 +-- .../top_nav/app_menu_actions/index.ts | 1 - .../run_app_menu_action.test.tsx | 121 ----------- .../app_menu_actions/run_app_menu_action.tsx | 193 ------------------ .../accessors/get_app_menu.tsx | 124 ++++++----- .../services/discover_features/types.ts | 4 + .../shared_flyout/create_slo_form_flyout.tsx | 23 ++- .../plugins/slo/public/plugin.ts | 10 + 13 files changed, 177 insertions(+), 712 deletions(-) delete mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.test.ts delete mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.ts delete mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx delete mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts index 5d8de8769be5e..c3858bed2952a 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts @@ -27,7 +27,7 @@ export class AppMenuRegistry { /** * Register a menu item. - * @param item The menu item to register (run accepts params) + * @param item The menu item to register */ public registerItem(item: AppMenuItemType) { this.items.set(item.id, item as AppMenuItemType); @@ -35,7 +35,7 @@ export class AppMenuRegistry { /** * Register multiple menu items at once. - * @param items Array of menu items to register (run accepts params) + * @param items Array of menu items to register */ public registerItems(items: AppMenuItemType[]) { items.forEach((item) => this.registerItem(item)); @@ -59,21 +59,22 @@ export class AppMenuRegistry { /** * Register a popover item for a specific parent menu item. - * Run function will be wrapped to handle parameters internally. * @param parentId The ID of the parent menu item - * @param popoverItem The popover item to register (run accepts params) + * @param popoverItem The popover item to register */ public registerPopoverItem(parentId: string, popoverItem: AppMenuPopoverItem) { this.items.set(parentId, { ...this.items.get(parentId), - items: [...(this.items.get(parentId)?.items || []), popoverItem], + items: [...(this.items.get(parentId)?.items || []), popoverItem].sort( + (a, b) => (a.order || 0) - (b.order || 0) + ), } as AppMenuItemType); } /** * Register multiple popover items for a specific parent menu item. * @param parentId The ID of the parent menu item - * @param popoverItems Array of popover items to register (run accepts params) + * @param popoverItems Array of popover items to register */ public registerPopoverItems(parentId: string, popoverItems: AppMenuPopoverItem[]) { popoverItems.forEach((item) => this.registerPopoverItem(parentId, item)); diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts index 8d8527117d356..2eb986d980720 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts @@ -7,39 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type React from 'react'; -import type { IconType } from '@elastic/eui'; - -export interface AppMenuControlOnClickParams { - anchorElement: HTMLElement; - onFinishAction: () => void; -} - -export interface TopNavMenuData { - testId?: string; - isLoading?: boolean; - label: string; - description?: string; - href?: string; - tooltip?: string | (() => string | undefined); - disableButton?: boolean | (() => boolean); -} - -export type AppMenuControlProps = TopNavMenuData & { - onClick: - | ((params: AppMenuControlOnClickParams) => Promise) - | ((params: AppMenuControlOnClickParams) => React.ReactNode | void) - | undefined; -}; - -export type AppMenuControlWithIconProps = AppMenuControlProps & { - iconType: IconType; -}; - -interface ControlWithOptionalIcon { - iconType?: IconType; -} - export enum AppMenuActionId { new = 'new', open = 'open', @@ -51,105 +18,3 @@ export enum AppMenuActionId { backgroundsearch = 'backgroundSearch', manageRulesAndConnectors = 'manageRulesAndConnectors', } - -export enum AppMenuActionType { - primary = 'primary', - secondary = 'secondary', - custom = 'custom', - submenuHorizontalRule = 'submenuHorizontalRule', -} - -export interface AppMenuActionBase { - readonly id: AppMenuActionId | string; - readonly order?: number | undefined; -} - -/** - * A secondary menu action - */ -export interface AppMenuActionSecondary extends AppMenuActionBase { - readonly type: AppMenuActionType.secondary; - readonly controlProps: AppMenuControlProps; -} - -/** - * A secondary submenu action - */ -export interface AppMenuSubmenuActionSecondary - extends Omit { - readonly controlProps: AppMenuControlProps & ControlWithOptionalIcon; -} - -/** - * A custom menu action - */ -export interface AppMenuActionCustom extends AppMenuActionBase { - readonly type: AppMenuActionType.custom; - readonly controlProps: AppMenuControlProps; -} - -/** - * A custom submenu action - */ -export interface AppMenuSubmenuActionCustom extends Omit { - readonly controlProps: AppMenuControlProps & ControlWithOptionalIcon; -} - -/** - * A primary menu action (with icon only) - */ -export interface AppMenuActionPrimary extends AppMenuActionBase { - readonly type: AppMenuActionType.primary; - readonly controlProps: AppMenuControlWithIconProps; -} - -/** - * A horizontal rule between menu items - */ -export interface AppMenuSubmenuHorizontalRule extends AppMenuActionBase { - readonly type: AppMenuActionType.submenuHorizontalRule; - readonly testId?: TopNavMenuData['testId']; -} - -/** - * A menu action which opens a submenu with more actions - */ -export interface AppMenuActionSubmenuBase - extends AppMenuActionBase { - readonly type: T extends AppMenuActionSecondary - ? AppMenuActionType.secondary - : AppMenuActionType.custom; - readonly label: TopNavMenuData['label']; - readonly testId?: TopNavMenuData['testId']; - readonly actions: T extends AppMenuActionSecondary - ? Array< - AppMenuSubmenuActionSecondary | AppMenuSubmenuActionCustom | AppMenuSubmenuHorizontalRule - > - : Array; -} - -/** - * A menu action which opens a submenu with more secondary actions - */ -export type AppMenuActionSubmenuSecondary = AppMenuActionSubmenuBase; -/** - * A menu action which opens a submenu with more custom actions - */ -export type AppMenuActionSubmenuCustom = AppMenuActionSubmenuBase; - -/** - * A primary menu item can only have an icon - */ -export type AppMenuItemPrimary = AppMenuActionPrimary; -/** - * A secondary menu item can have only a label or a submenu - */ -export type AppMenuItemSecondary = AppMenuActionSecondary | AppMenuActionSubmenuSecondary; -/** - * A custom menu item can have only a label or a submenu - */ -export type AppMenuItemCustom = AppMenuActionCustom | AppMenuActionSubmenuCustom; -/** - * A menu item can be primary, secondary or custom - */ -export type AppMenuItem = AppMenuItemPrimary | AppMenuItemSecondary | AppMenuItemCustom; diff --git a/src/platform/packages/shared/response-ops/rule_form/flyout/rule_form_flyout.tsx b/src/platform/packages/shared/response-ops/rule_form/flyout/rule_form_flyout.tsx index 42dba0a50d780..2d451e0cbc0f0 100644 --- a/src/platform/packages/shared/response-ops/rule_form/flyout/rule_form_flyout.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/flyout/rule_form_flyout.tsx @@ -30,15 +30,23 @@ const inLineContainerCss = css` interface RuleFormFlyoutRendererProps { ruleFormProps: RuleFormProps; focusTrapProps?: EuiFlyoutResizableProps['focusTrapProps']; + renderFlyout?: boolean; + onClose?: () => void; } const RuleFormFlyoutRenderer = ({ ruleFormProps, focusTrapProps, + renderFlyout = true, + onClose: externalOnClose, }: RuleFormFlyoutRendererProps) => { const { onClickClose, hideCloseButton } = useRuleFlyoutUIContext(); const onClose = useCallback(() => { + if (externalOnClose) { + externalOnClose(); + return; + } // If onClickClose has been initialized, call it instead of onCancel. onClickClose should be used to // determine if the close confirmation modal should be shown. props.onCancel is passed down the component hierarchy // and will be called 1) by onClickClose, if the confirmation modal doesn't need to be shown, or 2) by the confirm @@ -50,7 +58,24 @@ const RuleFormFlyoutRenderer = ({ // This will only occur if the user tries to close the flyout while the Suspense fallback is still visible ruleFormProps.onCancel?.(); } - }, [onClickClose, ruleFormProps]); + }, [onClickClose, ruleFormProps, externalOnClose]); + + const content = ( + + + + } + > + + + ); + + if (!renderFlyout) { + return content; + } + return ( ({ hideCloseButton={hideCloseButton} focusTrapProps={focusTrapProps} > - - - - } - > - - + {content} ); }; interface RuleFormFlyoutProps extends RuleFormProps { focusTrapProps?: EuiFlyoutResizableProps['focusTrapProps']; + renderFlyout?: boolean; + onClose?: () => void; } export const RuleFormFlyout = ({ focusTrapProps, + renderFlyout, + onClose, ...ruleFormProps }: RuleFormFlyoutProps) => { return ( - + ); }; + +/** + * RuleFormFlyout component without the flyout wrapper - for use with imperative mounting + * via overlays.openFlyout() + */ +export const RuleFormFlyoutContent = ( + props: RuleFormFlyoutProps +) => { + return ; +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.test.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.test.ts deleted file mode 100644 index 4cf11a5dd105b..0000000000000 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { - AppMenuActionPrimary, - AppMenuActionSecondary, - AppMenuActionSubmenuCustom, -} from '@kbn/discover-utils'; -import { AppMenuActionType } from '@kbn/discover-utils'; -import { convertAppMenuItemToTopNavItem } from './convert_to_top_nav_item'; -import { discoverServiceMock } from '../../../../../__mocks__/services'; - -describe('convertAppMenuItemToTopNavItem', () => { - it('should convert a primary AppMenuItem to TopNavMenuData', () => { - const appMenuItem: AppMenuActionPrimary = { - id: 'action-1', - type: AppMenuActionType.primary, - controlProps: { - label: 'Action 1', - testId: 'action-1', - iconType: 'share', - onClick: jest.fn(), - href: '/test-href', - }, - }; - - const topNavItem = convertAppMenuItemToTopNavItem({ - appMenuItem, - services: discoverServiceMock, - }); - - expect(topNavItem).toEqual({ - id: 'action-1', - label: 'Action 1', - description: 'Action 1', - testId: 'action-1', - run: expect.any(Function), - iconType: 'share', - iconOnly: true, - href: '/test-href', - }); - }); - - it('should convert a secondary AppMenuItem to TopNavMenuData', () => { - const appMenuItem: AppMenuActionSecondary = { - id: 'action-2', - type: AppMenuActionType.secondary, - controlProps: { - label: 'Action Secondary', - testId: 'action-secondary', - onClick: jest.fn(), - }, - }; - - const topNavItem = convertAppMenuItemToTopNavItem({ - appMenuItem, - services: discoverServiceMock, - }); - - expect(topNavItem).toEqual({ - id: 'action-2', - label: 'Action Secondary', - description: 'Action Secondary', - testId: 'action-secondary', - run: expect.any(Function), - }); - }); - - it('should convert a custom AppMenuItem to TopNavMenuData', () => { - const appMenuItem: AppMenuActionSubmenuCustom = { - id: 'action-3', - type: AppMenuActionType.custom, - label: 'Action submenu', - testId: 'action-submenu', - actions: [ - { - id: 'action-3-1', - type: AppMenuActionType.custom, - controlProps: { - label: 'Action 3.1', - testId: 'action-3-1', - onClick: jest.fn(), - }, - }, - { - id: 'action-3-2', - type: AppMenuActionType.submenuHorizontalRule, - }, - { - id: 'action-3-3', - type: AppMenuActionType.custom, - controlProps: { - label: 'Action 3.3', - testId: 'action-3-3', - onClick: jest.fn(), - }, - }, - ], - }; - - const topNavItem = convertAppMenuItemToTopNavItem({ - appMenuItem, - services: discoverServiceMock, - }); - - expect(topNavItem).toEqual({ - id: 'action-3', - label: 'Action submenu', - description: 'Action submenu', - testId: 'action-submenu', - run: expect.any(Function), - }); - }); -}); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.ts deleted file mode 100644 index bdba585019591..0000000000000 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/convert_to_top_nav_item.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { AppMenuItem } from '@kbn/discover-utils'; -import { AppMenuActionType } from '@kbn/discover-utils'; -import type { TopNavMenuData } from '@kbn/navigation-plugin/public'; -import { runAppMenuAction, runAppMenuPopoverAction } from './run_app_menu_action'; -import type { DiscoverServices } from '../../../../../build_services'; - -export function convertAppMenuItemToTopNavItem({ - appMenuItem, - services, -}: { - appMenuItem: AppMenuItem; - services: DiscoverServices; -}): TopNavMenuData { - if ('actions' in appMenuItem) { - return { - id: appMenuItem.id, - label: appMenuItem.label, - description: appMenuItem.description ?? appMenuItem.label, - testId: appMenuItem.testId, - run: (anchorElement: HTMLElement) => { - runAppMenuPopoverAction({ - appMenuItem, - anchorElement, - services, - }); - }, - }; - } - - return { - id: appMenuItem.id, - label: appMenuItem.controlProps.label, - description: appMenuItem.controlProps.description ?? appMenuItem.controlProps.label, - testId: appMenuItem.controlProps.testId, - run: async (anchorElement: HTMLElement) => { - await runAppMenuAction({ - appMenuItem, - anchorElement, - services, - }); - }, - ...(appMenuItem.type === AppMenuActionType.primary - ? { iconType: appMenuItem.controlProps.iconType, iconOnly: true } - : {}), - ...(appMenuItem.controlProps.href ? { href: appMenuItem.controlProps.href } : {}), - }; -} diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx index fdd26d57f3f89..671fdd148d60f 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx @@ -14,9 +14,11 @@ import { AppMenuActionId } from '@kbn/discover-utils'; import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; import { AlertConsumers, ES_QUERY_ID, STACK_ALERTS_FEATURE_ID } from '@kbn/rule-data-utils'; import type { RuleTypeMetaData } from '@kbn/alerting-plugin/common'; -import { RuleFormFlyout } from '@kbn/response-ops-rule-form/flyout'; +import { RuleFormFlyoutContent } from '@kbn/response-ops-rule-form/flyout'; import { isValidRuleFormPlugins } from '@kbn/response-ops-rule-form/lib'; import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; import type { AppMenuDiscoverParams } from './types'; import type { DiscoverServices } from '../../../../../build_services'; @@ -33,7 +35,7 @@ interface EsQueryAlertMetaData extends RuleTypeMetaData { adHocDataViewList: DataView[]; } -const RuleFormFlyoutWithType = RuleFormFlyout; +const RuleFormFlyoutWithType = RuleFormFlyoutContent; const CreateAlertFlyout: React.FC<{ discoverParams: AppMenuDiscoverParams; @@ -130,7 +132,7 @@ export const getAlertsAppMenuItem = ({ // activeSpace.solution && activeSpace.solution !== 'classic' TODO handle this items.push({ id: AppMenuActionId.manageRulesAndConnectors, - order: 2, + order: Number.MAX_SAFE_INTEGER, label: i18n.translate('discover.alerts.manageRulesAndConnectors', { defaultMessage: 'Manage rules and connectors', }), @@ -156,15 +158,20 @@ export const getAlertsAppMenuItem = ({ : i18n.translate('discover.alerts.missedTimeFieldToolTip', { defaultMessage: 'Data view does not have a time field.', }), - run: async () => { - // return ( - // - // ); + run: () => { + const overlay = services.core.overlays.openFlyout( + toMountPoint( + + overlay.close()} + stateContainer={stateContainer} + /> + , + services.core + ) + ); }, }); } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/index.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/index.ts index d124b88fda770..b6582d1f62e16 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/index.ts @@ -13,5 +13,4 @@ export { getOpenSearchAppMenuItem } from './get_open_search'; export { getShareAppMenuItem } from './get_share'; export { getInspectAppMenuItem } from './get_inspect'; export { getBackgroundSearchFlyout } from './get_background_search_flyout'; -export { convertAppMenuItemToTopNavItem } from './convert_to_top_nav_item'; export type * from './types'; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx deleted file mode 100644 index fe973ce06c90f..0000000000000 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React from 'react'; -import { screen } from '@testing-library/react'; -import type { AppMenuActionSubmenuCustom, AppMenuItem } from '@kbn/discover-utils'; -import { AppMenuActionType } from '@kbn/discover-utils'; -import { discoverServiceMock } from '../../../../../__mocks__/services'; -import { runAppMenuAction, runAppMenuPopoverAction } from './run_app_menu_action'; - -describe('run app menu actions', () => { - describe('runAppMenuAction', () => { - it('should call the action correctly', () => { - const appMenuItem: AppMenuItem = { - id: 'action-1', - type: AppMenuActionType.primary, - controlProps: { - label: 'Action 1', - testId: 'action-1', - iconType: 'share', - onClick: jest.fn(), - }, - }; - - const anchorElement = document.createElement('div'); - - runAppMenuAction({ - appMenuItem, - anchorElement, - services: discoverServiceMock, - }); - - expect(appMenuItem.controlProps.onClick).toHaveBeenCalled(); - }); - - it('should call the action and render a custom content', async () => { - const appMenuItem: AppMenuItem = { - id: 'action-1', - type: AppMenuActionType.primary, - controlProps: { - label: 'Action 1', - testId: 'action-1', - iconType: 'share', - onClick: jest.fn(({ onFinishAction }) => ( - - - -`; - -exports[` should show all menu items 2`] = ` -
-
- - - -
-
-`; diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/index.ts b/src/platform/packages/private/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/index.ts deleted file mode 100644 index 14ebb23d0f4c6..0000000000000 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export { UnsavedChangesBadge, type UnsavedChangesBadgeProps } from './unsaved_changes_badge'; diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.test.tsx b/src/platform/packages/private/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.test.tsx deleted file mode 100644 index d3b1996ebd3b7..0000000000000 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React from 'react'; -import { render, act, screen, waitFor } from '@testing-library/react'; -import { UnsavedChangesBadge } from './unsaved_changes_badge'; - -describe('', () => { - test('should render correctly', async () => { - const onRevert = jest.fn(); - const { getByTestId, queryByTestId } = render( - - ); - expect(getByTestId('unsavedChangesBadge')).toBeInTheDocument(); - - getByTestId('unsavedChangesBadge').click(); - await waitFor(() => { - return Boolean(queryByTestId('unsavedChangesBadgeMenuPanel')); - }); - expect(queryByTestId('revertUnsavedChangesButton')).toBeInTheDocument(); - expect(queryByTestId('saveUnsavedChangesButton')).not.toBeInTheDocument(); - expect(queryByTestId('saveUnsavedChangesAsButton')).not.toBeInTheDocument(); - - expect(onRevert).not.toHaveBeenCalled(); - - act(() => { - getByTestId('revertUnsavedChangesButton').click(); - }); - expect(onRevert).toHaveBeenCalled(); - }); - - test('should show all menu items', async () => { - const onRevert = jest.fn().mockResolvedValue(true); - const onSave = jest.fn().mockResolvedValue(true); - const onSaveAs = jest.fn().mockResolvedValue(true); - const { getByTestId, queryByTestId, container } = render( - - ); - - expect(container).toMatchSnapshot(); - - getByTestId('unsavedChangesBadge').click(); - await waitFor(() => { - return Boolean(queryByTestId('unsavedChangesBadgeMenuPanel')); - }); - expect(queryByTestId('revertUnsavedChangesButton')).toBeInTheDocument(); - expect(queryByTestId('saveUnsavedChangesButton')).toBeInTheDocument(); - expect(queryByTestId('saveUnsavedChangesAsButton')).toBeInTheDocument(); - - expect(screen.getByTestId('unsavedChangesBadgeMenuPanel')).toMatchSnapshot(); - }); - - test('should call callbacks', async () => { - const onRevert = jest.fn().mockResolvedValue(true); - const onSave = jest.fn().mockResolvedValue(true); - const onSaveAs = jest.fn().mockResolvedValue(true); - const { getByTestId, queryByTestId } = render( - - ); - act(() => { - getByTestId('unsavedChangesBadge').click(); - }); - await waitFor(() => { - return Boolean(queryByTestId('unsavedChangesBadgeMenuPanel')); - }); - - expect(onSave).not.toHaveBeenCalled(); - - act(() => { - getByTestId('saveUnsavedChangesButton').click(); - }); - expect(onSave).toHaveBeenCalled(); - }); -}); diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.tsx b/src/platform/packages/private/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.tsx deleted file mode 100644 index 275da7b23d494..0000000000000 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/src/components/unsaved_changes_badge/unsaved_changes_badge.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React, { useState } from 'react'; -import useMountedState from 'react-use/lib/useMountedState'; -import { - EuiBadge, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiPopover, - useGeneratedHtmlId, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -enum ProcessingType { - reverting = 'reverting', - saving = 'saving', - savingAs = 'savingAs', -} - -/** - * Props for UnsavedChangesBadge - */ -export interface UnsavedChangesBadgeProps { - onRevert: () => Promise; - onSave?: () => Promise; - onSaveAs?: () => Promise; - badgeText: string; -} - -/** - * Badge component. It opens a menu panel with actions once pressed. - * @param badgeText - * @param onRevert - * @param onSave - * @param onSaveAs - * @constructor - */ -export const UnsavedChangesBadge: React.FC = ({ - badgeText, - onRevert, - onSave, - onSaveAs, -}) => { - const isMounted = useMountedState(); - const [processingType, setProcessingType] = useState(null); - const [isPopoverOpen, setPopover] = useState(false); - const contextMenuPopoverId = useGeneratedHtmlId({ - prefix: 'unsavedChangesPopover', - }); - - const togglePopover = () => { - setPopover((value) => !value); - }; - - const closePopover = () => { - setPopover(false); - }; - - const completeMenuItemAction = () => { - if (isMounted()) { - setProcessingType(null); - closePopover(); - } - }; - - const handleMenuItem = async (type: ProcessingType, action: () => Promise) => { - try { - setProcessingType(type); - await action(); - } finally { - completeMenuItemAction(); - } - }; - - const disabled = Boolean(processingType); - const isSaving = processingType === ProcessingType.saving; - const isSavingAs = processingType === ProcessingType.savingAs; - const isReverting = processingType === ProcessingType.reverting; - - const items = [ - ...(onSave - ? [ - { - await handleMenuItem(ProcessingType.saving, onSave); - }} - > - {isSaving - ? i18n.translate('unsavedChangesBadge.contextMenu.savingChangesButtonStatus', { - defaultMessage: 'Saving...', - }) - : i18n.translate('unsavedChangesBadge.contextMenu.saveChangesButton', { - defaultMessage: 'Save', - })} - , - ] - : []), - ...(onSaveAs - ? [ - { - await handleMenuItem(ProcessingType.savingAs, onSaveAs); - }} - > - {isSavingAs - ? i18n.translate('unsavedChangesBadge.contextMenu.savingChangesAsButtonStatus', { - defaultMessage: 'Saving as...', - }) - : i18n.translate('unsavedChangesBadge.contextMenu.saveChangesAsButton', { - defaultMessage: 'Save as', - })} - , - ] - : []), - { - await handleMenuItem(ProcessingType.reverting, onRevert); - }} - > - {isReverting - ? i18n.translate('unsavedChangesBadge.contextMenu.revertingChangesButtonStatus', { - defaultMessage: 'Reverting changes...', - }) - : i18n.translate('unsavedChangesBadge.contextMenu.revertChangesButton', { - defaultMessage: 'Revert changes', - })} - , - ]; - - const button = ( - - {badgeText} - - ); - - return ( - - - - ); -}; diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/__snapshots__/get_top_nav_unsaved_changes_badge.test.tsx.snap b/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/__snapshots__/get_top_nav_unsaved_changes_badge.test.tsx.snap deleted file mode 100644 index c36750b8e5b9f..0000000000000 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/__snapshots__/get_top_nav_unsaved_changes_badge.test.tsx.snap +++ /dev/null @@ -1,60 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`getTopNavUnsavedChangesBadge() should work correctly 1`] = ` -
-
- -
-
-`; - -exports[`getTopNavUnsavedChangesBadge() should work correctly 2`] = ` -
-
- -
-
-`; diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.test.tsx b/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.test.tsx deleted file mode 100644 index be7a6fab6cb8b..0000000000000 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { render, waitFor, screen } from '@testing-library/react'; -import { getTopNavUnsavedChangesBadge } from './get_top_nav_unsaved_changes_badge'; - -describe('getTopNavUnsavedChangesBadge()', () => { - test('should work correctly', async () => { - const onRevert = jest.fn().mockResolvedValue(true); - const badge = getTopNavUnsavedChangesBadge({ onRevert }); - const { container, getByTestId, queryByTestId } = render( - badge.renderCustomBadge!({ badgeText: badge.badgeText }) - ); - expect(container).toMatchSnapshot(); - - getByTestId('unsavedChangesBadge').click(); - await waitFor(() => { - return Boolean(queryByTestId('revertUnsavedChangesButton')); - }); - - expect(screen.getByTestId('unsavedChangesBadgeMenuPanel')).toMatchSnapshot(); - }); -}); diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx b/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx deleted file mode 100644 index 899c6f05405ac..0000000000000 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/src/utils/get_top_nav_unsaved_changes_badge.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import type { ChromeBreadcrumbsBadge } from '@kbn/core-chrome-browser'; -import { - UnsavedChangesBadge, - type UnsavedChangesBadgeProps, -} from '../components/unsaved_changes_badge'; - -/** - * Params for getTopNavUnsavedChangesBadge - */ -export interface TopNavUnsavedChangesBadgeParams { - onRevert: UnsavedChangesBadgeProps['onRevert']; - onSave?: UnsavedChangesBadgeProps['onSave']; - onSaveAs?: UnsavedChangesBadgeProps['onSaveAs']; -} - -/** - * Returns a badge object suitable for the top nav `badges` prop - * @param onRevert - * @param onSave - * @param onSaveAs - */ -export const getTopNavUnsavedChangesBadge = ({ - onRevert, - onSave, - onSaveAs, -}: TopNavUnsavedChangesBadgeParams): ChromeBreadcrumbsBadge => { - return { - badgeText: i18n.translate('unsavedChangesBadge.unsavedChangesTitle', { - defaultMessage: 'Unsaved changes', - }), - renderCustomBadge: ({ badgeText }) => ( - - ), - }; -}; diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/tsconfig.json b/src/platform/packages/private/kbn-unsaved-changes-badge/tsconfig.json deleted file mode 100644 index 37fa6924386ef..0000000000000 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "@kbn/tsconfig-base/tsconfig.json", - "compilerOptions": { - "outDir": "target/types" - }, - "include": ["*.ts", "src/**/*", "__mocks__/**/*.ts"], - "exclude": [ - "target/**/*" - ], - "kbn_references": [ - "@kbn/i18n", - "@kbn/core-chrome-browser", - ] -} diff --git a/src/platform/packages/private/kbn-unsaved-changes-badge/types.ts b/src/platform/packages/private/kbn-unsaved-changes-badge/types.ts deleted file mode 100644 index b5a3c9b52ac60..0000000000000 --- a/src/platform/packages/private/kbn-unsaved-changes-badge/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export type { UnsavedChangesBadgeProps } from './src/components/unsaved_changes_badge'; -export type { TopNavUnsavedChangesBadgeParams } from './src/utils/get_top_nav_unsaved_changes_badge'; diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/__snapshots__/app_menu_registry.test.ts.snap b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/__snapshots__/app_menu_registry.test.ts.snap deleted file mode 100644 index 88ee3c6f55a76..0000000000000 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/__snapshots__/app_menu_registry.test.ts.snap +++ /dev/null @@ -1,184 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AppMenuRegistry should allow to override actions under submenu 1`] = ` -Array [ - Object { - "controlProps": Object { - "label": "Action 2", - "onClick": [MockFunction], - }, - "id": "action-2", - "order": 200, - "type": "secondary", - }, - Object { - "actions": Array [ - Object { - "controlProps": Object { - "label": "Action 3.2", - "onClick": [MockFunction], - }, - "id": "action-3-2", - "order": 200, - "type": "secondary", - }, - Object { - "controlProps": Object { - "label": "Action Custom", - "onClick": [MockFunction], - }, - "id": "action-3-1", - "type": "custom", - }, - ], - "id": "action-3", - "label": "Action 3", - "order": 300, - "type": "secondary", - }, - Object { - "controlProps": Object { - "iconType": "bell", - "label": "Action 1", - "onClick": [MockFunction], - }, - "id": "action-1", - "order": 100, - "type": "primary", - }, -] -`; - -exports[`AppMenuRegistry should allow to register custom actions 1`] = ` -Array [ - Object { - "controlProps": Object { - "label": "Action Custom", - "onClick": [MockFunction], - }, - "id": "action-custom", - "type": "custom", - }, - Object { - "actions": Array [ - Object { - "controlProps": Object { - "label": "Action Custom Submenu 1", - "onClick": [MockFunction], - }, - "id": "action-custom-submenu-1", - "type": "custom", - }, - ], - "id": "action-custom-submenu", - "label": "Action Custom Submenu", - "type": "custom", - }, - Object { - "controlProps": Object { - "label": "Action 2", - "onClick": [MockFunction], - }, - "id": "action-2", - "order": 200, - "type": "secondary", - }, - Object { - "actions": Array [ - Object { - "controlProps": Object { - "iconType": "heart", - "label": "Action 3.1", - "onClick": [MockFunction], - }, - "id": "action-3-1", - "order": 100, - "type": "secondary", - }, - Object { - "controlProps": Object { - "label": "Action 3.2", - "onClick": [MockFunction], - }, - "id": "action-3-2", - "order": 200, - "type": "secondary", - }, - ], - "id": "action-3", - "label": "Action 3", - "order": 300, - "type": "secondary", - }, - Object { - "controlProps": Object { - "iconType": "bell", - "label": "Action 1", - "onClick": [MockFunction], - }, - "id": "action-1", - "order": 100, - "type": "primary", - }, -] -`; - -exports[`AppMenuRegistry should allow to register custom actions under submenu 1`] = ` -Array [ - Object { - "controlProps": Object { - "label": "Action 2", - "onClick": [MockFunction], - }, - "id": "action-2", - "order": 200, - "type": "secondary", - }, - Object { - "actions": Array [ - Object { - "controlProps": Object { - "iconType": "heart", - "label": "Action 3.1", - "onClick": [MockFunction], - }, - "id": "action-3-1", - "order": 100, - "type": "secondary", - }, - Object { - "controlProps": Object { - "label": "Action Custom", - "onClick": [MockFunction], - }, - "id": "action-custom", - "order": 101, - "type": "custom", - }, - Object { - "controlProps": Object { - "label": "Action 3.2", - "onClick": [MockFunction], - }, - "id": "action-3-2", - "order": 200, - "type": "secondary", - }, - ], - "id": "action-3", - "label": "Action 3", - "order": 300, - "type": "secondary", - }, - Object { - "controlProps": Object { - "iconType": "bell", - "label": "Action 1", - "onClick": [MockFunction], - }, - "id": "action-1", - "order": 100, - "type": "primary", - }, -] -`; diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts index 71bad77c4ef23..d2dd189fb55ad 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts @@ -8,178 +8,317 @@ */ import { AppMenuRegistry } from './app_menu_registry'; -import type { AppMenuActionSubmenuSecondary, AppMenuSubmenuActionCustom } from './types'; -import { AppMenuActionType } from './types'; +import type { AppMenuItemType, AppMenuPopoverItem } from '@kbn/core-chrome-app-menu-components'; describe('AppMenuRegistry', () => { - it('should initialize correctly', () => { - const appMenuRegistry = initializeAppMenuRegistry(); - expect(appMenuRegistry.isActionRegistered('action-1')).toBe(true); - expect(appMenuRegistry.isActionRegistered('action-2')).toBe(true); - expect(appMenuRegistry.isActionRegistered('action-3')).toBe(true); - expect(appMenuRegistry.isActionRegistered('action-3-1')).toBe(true); - expect(appMenuRegistry.isActionRegistered('action-3-2')).toBe(true); - expect(appMenuRegistry.isActionRegistered('action-n')).toBe(false); - expect(appMenuRegistry.getSortedItems()).toHaveLength(3); + let registry: AppMenuRegistry; + + beforeEach(() => { + registry = new AppMenuRegistry(); }); - it('should allow to register custom actions', () => { - const appMenuRegistry = initializeAppMenuRegistry(); - expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(false); - - appMenuRegistry.registerCustomAction({ - id: 'action-custom', - type: AppMenuActionType.custom, - controlProps: { - label: 'Action Custom', - onClick: jest.fn(), - }, + describe('registerItem', () => { + it('should register a single menu item', () => { + const item: AppMenuItemType = { + id: 'test-item', + order: 1, + label: 'Test Item', + iconType: 'search', + testId: 'testItem', + run: jest.fn(), + }; + + registry.registerItem(item); + + const config = registry.getAppMenuConfig(); + expect(config.items).toHaveLength(1); + expect(config.items![0]).toEqual(item); + }); + + it('should update an existing item with the same ID', () => { + const item1: AppMenuItemType = { + id: 'test-item', + order: 1, + label: 'Test Item 1', + iconType: 'search', + run: jest.fn(), + }; + + const item2: AppMenuItemType = { + id: 'test-item', + order: 2, + label: 'Test Item 2', + iconType: 'bell', + run: jest.fn(), + }; + + registry.registerItem(item1); + registry.registerItem(item2); + + const config = registry.getAppMenuConfig(); + expect(config.items).toHaveLength(1); + expect(config.items![0]).toEqual(item2); }); + }); - appMenuRegistry.registerCustomAction({ - id: 'action-custom-submenu', - type: AppMenuActionType.custom, - label: 'Action Custom Submenu', - actions: [ + describe('registerItems', () => { + it('should register multiple items at once', () => { + const items: AppMenuItemType[] = [ + { + id: 'item-1', + order: 1, + label: 'Item 1', + iconType: 'search', + run: jest.fn(), + }, { - id: 'action-custom-submenu-1', - type: AppMenuActionType.custom, - controlProps: { - label: 'Action Custom Submenu 1', - onClick: jest.fn(), - }, + id: 'item-2', + order: 2, + label: 'Item 2', + iconType: 'alert', + run: jest.fn(), }, - ], + { + id: 'item-3', + order: 3, + label: 'Item 3', + iconType: 'bell', + run: jest.fn(), + }, + ]; + + registry.registerItems(items); + + const config = registry.getAppMenuConfig(); + expect(config.items).toHaveLength(3); }); + }); + + describe('setPrimaryActionItem', () => { + it('should set the primary action item', () => { + const primaryItem = { + id: 'primary', + label: 'Primary', + iconType: 'save', + run: jest.fn(), + testId: 'primaryButton', + }; - expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(true); - expect(appMenuRegistry.isActionRegistered('action-custom-submenu')).toBe(true); - expect(appMenuRegistry.getSortedItems()).toHaveLength(5); - - appMenuRegistry.registerCustomAction({ - id: 'action-custom-extra', - type: AppMenuActionType.custom, - controlProps: { - label: 'Action Custom Extra', - onClick: jest.fn(), - }, + registry.setPrimaryActionItem(primaryItem); + + const config = registry.getAppMenuConfig(); + expect(config.primaryActionItem).toEqual(primaryItem); }); - // should limit the number of custom items - const items = appMenuRegistry.getSortedItems(); - expect(items).toHaveLength(5); - expect(items).toMatchSnapshot(); + it('should allow updating the primary action item', () => { + const primaryItem1 = { + id: 'primary', + label: 'Primary 1', + iconType: 'save', + run: jest.fn(), + }; + + const primaryItem2 = { + id: 'primary', + label: 'Primary 2', + iconType: 'save', + run: jest.fn(), + }; + + registry.setPrimaryActionItem(primaryItem1); + registry.setPrimaryActionItem(primaryItem2); + + const config = registry.getAppMenuConfig(); + expect(config.primaryActionItem).toEqual(primaryItem2); + }); }); - it('should allow to register custom actions under submenu', () => { - const appMenuRegistry = initializeAppMenuRegistry(); - expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(false); - - let items = appMenuRegistry.getSortedItems(); - let submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary; - expect(items).toHaveLength(3); - expect(submenuItem.actions).toHaveLength(2); - - appMenuRegistry.registerCustomActionUnderSubmenu('action-3', { - id: 'action-custom', - type: AppMenuActionType.custom, - order: 101, - controlProps: { - label: 'Action Custom', - onClick: jest.fn(), - }, + describe('setSecondaryActionItem', () => { + it('should set the secondary action item', () => { + const secondaryItem = { + id: 'secondary', + label: 'Secondary', + iconType: 'cross', + run: jest.fn(), + testId: 'secondaryButton', + }; + + registry.setSecondaryActionItem(secondaryItem); + + const config = registry.getAppMenuConfig(); + expect(config.secondaryActionItem).toEqual(secondaryItem); + }); + }); + + describe('registerPopoverItem', () => { + it('should register a popover item under a parent menu item', () => { + const parentItem: AppMenuItemType = { + id: 'parent', + order: 1, + label: 'Parent', + iconType: 'alert', + items: [], + }; + + const popoverItem: AppMenuPopoverItem = { + id: 'child-1', + order: 1, + label: 'Child 1', + iconType: 'bell', + run: jest.fn(), + }; + + registry.registerItem(parentItem); + registry.registerPopoverItem('parent', popoverItem); + + const config = registry.getAppMenuConfig(); + const parent = config.items!.find((item) => item.id === 'parent'); + + expect(parent?.items).toBeDefined(); + expect(parent?.items).toHaveLength(1); + expect(parent?.items?.[0]).toEqual(popoverItem); + }); + + it('should sort popover items by order property', () => { + const parentItem: AppMenuItemType = { + id: 'parent', + order: 1, + label: 'Parent', + iconType: 'alert', + items: [], + }; + + const popoverItem1: AppMenuPopoverItem = { + id: 'child-1', + label: 'Child 1', + order: 3, + run: jest.fn(), + }; + + const popoverItem2: AppMenuPopoverItem = { + id: 'child-2', + label: 'Child 2', + order: 1, + run: jest.fn(), + }; + + const popoverItem3: AppMenuPopoverItem = { + id: 'child-3', + label: 'Child 3', + order: 2, + run: jest.fn(), + }; + + registry.registerItem(parentItem); + registry.registerPopoverItem('parent', popoverItem1); + registry.registerPopoverItem('parent', popoverItem2); + registry.registerPopoverItem('parent', popoverItem3); + + const config = registry.getAppMenuConfig(); + const parent = config.items!.find((item) => item.id === 'parent'); + + expect(parent?.items).toHaveLength(3); + expect(parent?.items?.[0].id).toBe('child-2'); + expect(parent?.items?.[1].id).toBe('child-3'); + expect(parent?.items?.[2].id).toBe('child-1'); }); - expect(appMenuRegistry.isActionRegistered('action-custom')).toBe(true); + it('should handle popover items without order property', () => { + const parentItem: AppMenuItemType = { + id: 'parent', + order: 1, + label: 'Parent', + iconType: 'alert', + items: [], + }; + + const popoverItem1: AppMenuPopoverItem = { + id: 'child-1', + order: 0, + label: 'Child 1', + run: jest.fn(), + }; + + const popoverItem2: AppMenuPopoverItem = { + id: 'child-2', + label: 'Child 2', + order: 1, + run: jest.fn(), + }; - items = appMenuRegistry.getSortedItems(); - expect(items).toHaveLength(3); + registry.registerItem(parentItem); + registry.registerPopoverItem('parent', popoverItem1); + registry.registerPopoverItem('parent', popoverItem2); - // calling it again should not add a duplicate - items = appMenuRegistry.getSortedItems(); - expect(items).toHaveLength(3); + const config = registry.getAppMenuConfig(); + const parent = config.items!.find((item) => item.id === 'parent'); - submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary; - expect(submenuItem.actions).toHaveLength(3); - expect(items).toMatchSnapshot(); + expect(parent?.items).toHaveLength(2); + expect(parent?.items?.[0].id).toBe('child-1'); + expect(parent?.items?.[1].id).toBe('child-2'); + }); }); - it('should allow to override actions under submenu', () => { - const appMenuRegistry = initializeAppMenuRegistry(); - - let items = appMenuRegistry.getSortedItems(); - expect(items).toHaveLength(3); - - let submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary; - const existingSecondaryActionId = submenuItem.actions[0].id; - expect(submenuItem.actions).toHaveLength(2); - - expect(appMenuRegistry.isActionRegistered(existingSecondaryActionId)).toBe(true); - - const customAction: AppMenuSubmenuActionCustom = { - id: existingSecondaryActionId, // using the same id to override the action with a custom one - type: AppMenuActionType.custom, - controlProps: { - label: 'Action Custom', - onClick: jest.fn(), - }, - }; - appMenuRegistry.registerCustomActionUnderSubmenu('action-3', customAction); - - expect(appMenuRegistry.isActionRegistered(existingSecondaryActionId)).toBe(true); - - items = appMenuRegistry.getSortedItems(); - submenuItem = items.find((item) => item.id === 'action-3') as AppMenuActionSubmenuSecondary; - expect(submenuItem.actions).toHaveLength(2); - expect(submenuItem.actions.find((item) => item.id === existingSecondaryActionId)).toBe( - customAction - ); - expect(items).toMatchSnapshot(); + describe('getAppMenuConfig', () => { + it('should return complete AppMenuConfig with all components', () => { + const item1: AppMenuItemType = { + id: 'item-1', + order: 1, + label: 'Item 1', + iconType: 'search', + run: jest.fn(), + }; + + const item2: AppMenuItemType = { + id: 'item-2', + order: 2, + label: 'Item 2', + iconType: 'share', + items: [], + }; + + const popoverItem: AppMenuPopoverItem = { + id: 'popover-1', + order: 1, + label: 'Popover 1', + run: jest.fn(), + }; + + const primaryItem = { + id: 'primary', + label: 'Save', + iconType: 'save', + run: jest.fn(), + }; + + const secondaryItem = { + id: 'secondary', + label: 'Cancel', + iconType: 'cross', + run: jest.fn(), + }; + + registry.registerItems([item1, item2]); + registry.registerPopoverItem('item-2', popoverItem); + registry.setPrimaryActionItem(primaryItem); + registry.setSecondaryActionItem(secondaryItem); + + const config = registry.getAppMenuConfig(); + + expect(config.items).toHaveLength(2); + expect(config.primaryActionItem).toEqual(primaryItem); + expect(config.secondaryActionItem).toEqual(secondaryItem); + + const item2Config = config.items!.find((item) => item.id === 'item-2'); + expect(item2Config?.items).toHaveLength(1); + }); + + it('should return empty items array when no items registered', () => { + const config = registry.getAppMenuConfig(); + + expect(config.items).toEqual([]); + expect(config.primaryActionItem).toBeUndefined(); + expect(config.secondaryActionItem).toBeUndefined(); + }); }); }); - -function initializeAppMenuRegistry() { - return new AppMenuRegistry([ - { - id: 'action-1', - type: AppMenuActionType.primary, - controlProps: { - label: 'Action 1', - iconType: 'bell', - onClick: jest.fn(), - }, - }, - { - id: 'action-2', - type: AppMenuActionType.secondary, - controlProps: { - label: 'Action 2', - onClick: jest.fn(), - }, - }, - { - id: 'action-3', - type: AppMenuActionType.secondary, - label: 'Action 3', - actions: [ - { - id: 'action-3-1', - type: AppMenuActionType.secondary, - controlProps: { - label: 'Action 3.1', - iconType: 'heart', - onClick: jest.fn(), - }, - }, - { - id: 'action-3-2', - type: AppMenuActionType.secondary, - controlProps: { - label: 'Action 3.2', - onClick: jest.fn(), - }, - }, - ], - }, - ]); -} diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts index c3858bed2952a..8d9e8c425c8c7 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts @@ -66,39 +66,20 @@ export class AppMenuRegistry { this.items.set(parentId, { ...this.items.get(parentId), items: [...(this.items.get(parentId)?.items || []), popoverItem].sort( - (a, b) => (a.order || 0) - (b.order || 0) + (a: AppMenuPopoverItem, b: AppMenuPopoverItem) => (a.order || 0) - (b.order || 0) ), } as AppMenuItemType); } - /** - * Register multiple popover items for a specific parent menu item. - * @param parentId The ID of the parent menu item - * @param popoverItems Array of popover items to register - */ - public registerPopoverItems(parentId: string, popoverItems: AppMenuPopoverItem[]) { - popoverItems.forEach((item) => this.registerPopoverItem(parentId, item)); - } - - /** - * Check if an item with the given ID is registered. - * @param itemId The ID to check - */ - public isItemRegistered(itemId: string): boolean { - if (this.items.has(itemId)) { - return true; - } - - return false; - } - /** * Get the complete AppMenuConfig. * Items with registered popover items will have their items property populated. */ public getAppMenuConfig(): AppMenuConfig { return { - items: Array.from(this.items.values()), + items: Array.from(this.items.values()).sort( + (a: AppMenuItemType, b: AppMenuItemType) => (a.order || 0) - (b.order || 0) + ), primaryActionItem: this.primaryActionItem, secondaryActionItem: this.secondaryActionItem, }; diff --git a/src/platform/packages/shared/kbn-discover-utils/src/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/types.ts index cb4332d0f3648..1b6513a57493d 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/types.ts @@ -18,7 +18,7 @@ export type { RowControlRowProps, } from './components/custom_control_columns/types'; export type * from './components/app_menu/types'; -export { AppMenuActionId, AppMenuActionType } from './components/app_menu/types'; +export { AppMenuActionId } from './components/app_menu/types'; type DiscoverSearchHit = SearchHit>; diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index 6c5f3781934a2..72bc99067cc7e 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -77,7 +77,6 @@ dependsOn: - '@kbn/no-data-page-plugin' - '@kbn/global-search-plugin' - '@kbn/resizable-layout' - - '@kbn/unsaved-changes-badge' - '@kbn/rule-data-utils' - '@kbn/core-chrome-browser' - '@kbn/core-plugins-server' diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/index.ts b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/index.ts index b76d6a2710701..d6caf1dba08ed 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { TabsView, TabsBarWithAppMenu } from './tabs_view'; +export { TabsView } from './tabs_view'; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx index 9dc1aa73eaeec..4248377f941f5 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx @@ -12,7 +12,6 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { ES_QUERY_ID } from '@kbn/rule-data-utils'; -import { AppMenuActionsMenuPopover } from './run_app_menu_action'; import { getAlertsAppMenuItem } from './get_alerts'; import { discoverServiceMock } from '../../../../../__mocks__/services'; import { dataViewWithTimefieldMock } from '../../../../../__mocks__/data_view_with_timefield'; @@ -44,14 +43,9 @@ const mount = ( stateContainer, }); - return mountWithIntl( - - ); + void alertsAppMenuItem; // TODO + + return mountWithIntl(
); }; describe('OpenAlertsPopover', () => { diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx index 671fdd148d60f..8c7cbe04ba00a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx @@ -129,7 +129,6 @@ export const getAlertsAppMenuItem = ({ const items = []; if (services.capabilities.management?.insightsAndAlerting?.triggersActions) { - // activeSpace.solution && activeSpace.solution !== 'classic' TODO handle this items.push({ id: AppMenuActionId.manageRulesAndConnectors, order: Number.MAX_SAFE_INTEGER, diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 27b68942c684b..8bbd1c92b99ad 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -199,7 +199,7 @@ const getExportItems = ( } }; - const exportItems = exportIntegrations + const exportItems: AppMenuPopoverItem[] = exportIntegrations .filter((item) => item.shareType === 'integration') .map((item) => ({ ...mapIntegrationToMetaData(item.id), @@ -211,7 +211,7 @@ const getExportItems = ( }, })); - const derivativeItems = exportDerivatives + const derivativeItems: AppMenuPopoverItem[] = exportDerivatives .filter( (item): item is typeof item & { shareType: 'integration'; id: string } => item.shareType === 'integration' && item.groupId === 'exportDerivatives' diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.test.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.test.ts index 4b79b765b964c..9bfceee458fca 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.test.ts @@ -22,22 +22,18 @@ discoverServiceMock.capabilities.discover_v2.save = true; describe('getTopNavBadges()', function () { test('should not return the unsaved changes badge if no changes', () => { const topNavBadges = getTopNavBadges({ - hasUnsavedChanges: false, isMobile: false, services: discoverServiceMock, stateContainer, - topNavCustomization: undefined, }); expect(topNavBadges).toMatchInlineSnapshot(`Array []`); }); test('should return the unsaved changes badge when has changes', async () => { const topNavBadges = getTopNavBadges({ - hasUnsavedChanges: true, isMobile: false, services: discoverServiceMock, stateContainer, - topNavCustomization: undefined, }); expect(topNavBadges).toMatchInlineSnapshot(` Array [ @@ -63,11 +59,9 @@ describe('getTopNavBadges()', function () { const discoverServiceMockReadOnly = createDiscoverServicesMock(); discoverServiceMockReadOnly.capabilities.discover_v2.save = false; const topNavBadges = getTopNavBadges({ - hasUnsavedChanges: true, isMobile: false, services: discoverServiceMockReadOnly, stateContainer, - topNavCustomization: undefined, }); expect(topNavBadges).toHaveLength(1); @@ -88,11 +82,9 @@ describe('getTopNavBadges()', function () { test('should return the managed badge when managed saved search', () => { const topNavBadges = getTopNavBadges({ - hasUnsavedChanges: false, isMobile: false, services: discoverServiceMock, stateContainer: stateContainerWithManagedSavedSearch, - topNavCustomization: undefined, }); expect(topNavBadges).toHaveLength(1); @@ -101,11 +93,9 @@ describe('getTopNavBadges()', function () { test('should not show save in unsaved changed badge', async () => { const topNavBadges = getTopNavBadges({ - hasUnsavedChanges: true, isMobile: false, services: discoverServiceMock, stateContainer: stateContainerWithManagedSavedSearch, - topNavCustomization: undefined, }); expect(topNavBadges).toHaveLength(2); @@ -120,18 +110,9 @@ describe('getTopNavBadges()', function () { test('should not return the unsaved changes badge when disabled in customization', () => { const topNavBadges = getTopNavBadges({ - hasUnsavedChanges: true, isMobile: false, services: discoverServiceMock, stateContainer, - topNavCustomization: { - id: 'top_nav', - defaultBadges: { - unsavedChangesBadge: { - disabled: true, - }, - }, - }, }); expect(topNavBadges).toMatchInlineSnapshot(`Array []`); }); @@ -143,11 +124,9 @@ describe('getTopNavBadges()', function () { test('should return the solutions view badge when spaces is enabled', () => { const topNavBadges = getTopNavBadges({ - hasUnsavedChanges: false, isMobile: false, services: discoverServiceWithSpacesMock, stateContainer, - topNavCustomization: undefined, }); expect(topNavBadges).toMatchInlineSnapshot(` Array [ diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.test.tsx index c6073e10a4da9..981cd96b3d262 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/solutions_view_badge.test.tsx @@ -12,13 +12,10 @@ import { of } from 'rxjs'; import { SolutionsViewBadge } from './solutions_view_badge'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { DiscoverTestProvider } from '../../../../__mocks__/test_provider'; +import type { DiscoverServices } from '../../../../build_services'; -jest.mock('../../../../hooks/use_discover_services'); -const useDiscoverServicesMock = jest.mocked(useDiscoverServices); - -const mockUseDiscoverServicesMock = ({ +const createMockServices = ({ getActiveSpaceReturn, isSolutionViewEnabled, canManageSpaces = true, @@ -26,8 +23,8 @@ const mockUseDiscoverServicesMock = ({ getActiveSpaceReturn: object | undefined; isSolutionViewEnabled: boolean; canManageSpaces?: boolean; -}) => { - useDiscoverServicesMock.mockReturnValue({ +}): DiscoverServices => { + return { spaces: { getActiveSpace$: jest.fn().mockReturnValue(of(getActiveSpaceReturn)), isSolutionViewEnabled, @@ -37,15 +34,15 @@ const mockUseDiscoverServicesMock = ({ }, addBasePath: (path: string) => path, capabilities: { spaces: { manage: canManageSpaces } }, - } as unknown as ReturnType); + } as unknown as DiscoverServices; }; -const setup = () => { +const setup = (services: DiscoverServices) => { const user = userEvent.setup(); const { container } = render( - + ); @@ -56,7 +53,7 @@ describe('SolutionsViewBadge', () => { describe('when the solution visibility feature is disabled', () => { it('does not render the badge', () => { // Given - mockUseDiscoverServicesMock({ + const services = createMockServices({ getActiveSpaceReturn: { id: 'default', solution: 'classic', @@ -65,7 +62,7 @@ describe('SolutionsViewBadge', () => { }); // When - const { container } = setup(); + const { container } = setup(services); // Then expect(container).toBeEmptyDOMElement(); @@ -75,13 +72,13 @@ describe('SolutionsViewBadge', () => { describe('when spaces is disabled (no active space available)', () => { it('does not render the badge', () => { // Given - mockUseDiscoverServicesMock({ + const services = createMockServices({ getActiveSpaceReturn: undefined, isSolutionViewEnabled: true, }); // When - const { container } = setup(); + const { container } = setup(services); // Then expect(container).toBeEmptyDOMElement(); @@ -92,7 +89,7 @@ describe('SolutionsViewBadge', () => { describe('when the active space is configured to use a solution view other than "classic"', () => { it('does not render the badge', () => { // Given - mockUseDiscoverServicesMock({ + const services = createMockServices({ getActiveSpaceReturn: { id: 'default', solution: 'oblt', @@ -101,7 +98,7 @@ describe('SolutionsViewBadge', () => { }); // When - const { container } = setup(); + const { container } = setup(services); // Then expect(container).toBeEmptyDOMElement(); @@ -111,7 +108,7 @@ describe('SolutionsViewBadge', () => { describe('when the active space is configured to use the classic solution view', () => { it('renders the badge', () => { // Given - mockUseDiscoverServicesMock({ + const services = createMockServices({ getActiveSpaceReturn: { id: 'default', solution: 'classic', @@ -121,7 +118,7 @@ describe('SolutionsViewBadge', () => { }); // When - setup(); + setup(services); // Then expect(screen.getByText('Toggle popover')).toBeVisible(); @@ -131,7 +128,7 @@ describe('SolutionsViewBadge', () => { describe('and the user has manage spaces capability', () => { it('opens the popover', async () => { // Given - mockUseDiscoverServicesMock({ + const services = createMockServices({ getActiveSpaceReturn: { id: 'default', solution: 'classic', @@ -141,7 +138,7 @@ describe('SolutionsViewBadge', () => { }); // When - const { user } = setup(); + const { user } = setup(services); // Then await user.click(screen.getByTitle('Toggle popover')); @@ -160,7 +157,7 @@ describe('SolutionsViewBadge', () => { describe('and the user does not have manage spaces capability', () => { it('opens the popover', async () => { // Given - mockUseDiscoverServicesMock({ + const services = createMockServices({ getActiveSpaceReturn: { id: 'default', solution: 'classic', @@ -170,7 +167,7 @@ describe('SolutionsViewBadge', () => { }); // When - const { user } = setup(); + const { user } = setup(services); // Then await user.click(screen.getByTitle('Toggle popover')); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index 8bca49a35e9a9..bdb218439a870 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -76,112 +76,33 @@ describe('useTopNavLinks', () => { }; it('should return results', () => { - const topNavLinks = setup(); + const appMenuConfig = setup(); - expect(topNavLinks).toMatchInlineSnapshot(` - Array [ - Object { - "color": "text", - "emphasize": true, - "fill": false, - "id": "esql", - "label": "Try ES|QL", - "run": [Function], - "testId": "select-text-based-language-btn", - "tooltip": "ES|QL is Elastic's powerful new piped query language.", - }, - Object { - "description": "Open Inspector for search", - "id": "inspect", - "label": "Inspect", - "run": [Function], - "testId": "openInspectorButton", - }, - Object { - "description": "New session", - "iconOnly": true, - "iconType": "plus", - "id": "new", - "label": "New session", - "run": [Function], - "testId": "discoverNewButton", - }, - Object { - "description": "Open session", - "iconOnly": true, - "iconType": "folderOpen", - "id": "open", - "label": "Open session", - "run": [Function], - "testId": "discoverOpenButton", - }, - Object { - "description": "Save session", - "emphasize": true, - "iconType": "save", - "id": "save", - "label": "Save", - "run": [Function], - "testId": "discoverSaveButton", - }, - ] - `); + expect(appMenuConfig.items).toBeDefined(); + expect(appMenuConfig.items!.length).toBeGreaterThan(0); + + // Check for key items + const itemIds = appMenuConfig.items!.map((item) => item.id); + expect(itemIds).toContain('new'); + expect(itemIds).toContain('open'); + + // Check primary action item (Save) + expect(appMenuConfig.primaryActionItem).toBeDefined(); + expect(appMenuConfig.primaryActionItem?.label).toBe('Save'); }); describe('when ES|QL mode is true', () => { it('should return results', () => { - const topNavLinks = setup({ + const appMenuConfig = setup({ isEsqlMode: true, }); - expect(topNavLinks).toMatchInlineSnapshot(` - Array [ - Object { - "color": "text", - "emphasize": true, - "fill": false, - "id": "esql", - "label": "Switch to classic", - "run": [Function], - "testId": "switch-to-dataviews", - "tooltip": "Switch to KQL or Lucene syntax.", - }, - Object { - "description": "Open Inspector for search", - "id": "inspect", - "label": "Inspect", - "run": [Function], - "testId": "openInspectorButton", - }, - Object { - "description": "New session", - "iconOnly": true, - "iconType": "plus", - "id": "new", - "label": "New session", - "run": [Function], - "testId": "discoverNewButton", - }, - Object { - "description": "Open session", - "iconOnly": true, - "iconType": "folderOpen", - "id": "open", - "label": "Open session", - "run": [Function], - "testId": "discoverOpenButton", - }, - Object { - "description": "Save session", - "emphasize": true, - "iconType": "save", - "id": "save", - "label": "Save", - "run": [Function], - "testId": "discoverSaveButton", - }, - ] - `); + expect(appMenuConfig.items).toBeDefined(); + + // Check for ESQL switch item + const esqlItem = appMenuConfig.items!.find((item) => item.id === 'esql'); + expect(esqlItem).toBeDefined(); + expect(esqlItem?.label).toBe('Switch to classic'); }); }); @@ -195,69 +116,18 @@ describe('useTopNavLinks', () => { }); it('should include the share menu item', () => { - const topNavLinks = setup(); + const appMenuConfig = setup(); - expect(topNavLinks).toMatchInlineSnapshot(` - Array [ - Object { - "color": "text", - "emphasize": true, - "fill": false, - "id": "esql", - "label": "Try ES|QL", - "run": [Function], - "testId": "select-text-based-language-btn", - "tooltip": "ES|QL is Elastic's powerful new piped query language.", - }, - Object { - "description": "Open Inspector for search", - "id": "inspect", - "label": "Inspect", - "run": [Function], - "testId": "openInspectorButton", - }, - Object { - "description": "New session", - "iconOnly": true, - "iconType": "plus", - "id": "new", - "label": "New session", - "run": [Function], - "testId": "discoverNewButton", - }, - Object { - "description": "Open session", - "iconOnly": true, - "iconType": "folderOpen", - "id": "open", - "label": "Open session", - "run": [Function], - "testId": "discoverOpenButton", - }, - Object { - "description": "Share Discover session", - "iconOnly": true, - "iconType": "share", - "id": "share", - "label": "Share", - "run": [Function], - "testId": "shareTopNavButton", - }, - Object { - "description": "Save session", - "emphasize": true, - "iconType": "save", - "id": "save", - "label": "Save", - "run": [Function], - "testId": "discoverSaveButton", - }, - ] - `); + expect(appMenuConfig.items).toBeDefined(); + + // Check for share item + const shareItem = appMenuConfig.items!.find((item) => item.id === 'share'); + expect(shareItem).toBeDefined(); + expect(shareItem?.label).toBe('Share'); }); it('should include the export menu item', () => { - const topNavLinks = renderHook( + const appMenuConfig = renderHook( () => useTopNavLinks({ dataView: dataViewMock, @@ -276,7 +146,14 @@ describe('useTopNavLinks', () => { wrapper: Wrapper, } ).result.current; - expect(topNavLinks.filter((obj) => obj.id === 'export')).toBeDefined(); + + // Check for share item with export popover items + const shareItem = appMenuConfig.items!.find((item) => item.id === 'share'); + expect(shareItem).toBeDefined(); + + // Export should be a popover item under share + const exportItem = shareItem?.items?.find((item) => item.id === 'export'); + expect(exportItem).toBeDefined(); }); }); @@ -290,17 +167,23 @@ describe('useTopNavLinks', () => { }); it('should return the background search menu item', () => { - const topNavLinks = setup(); + const appMenuConfig = setup(); - expect(topNavLinks.filter((obj) => obj.id === 'backgroundSearch')).toBeDefined(); + const backgroundSearchItem = appMenuConfig.items!.find( + (item) => item.id === 'backgroundSearch' + ); + expect(backgroundSearchItem).toBeDefined(); }); }); describe('when background search is disabled', () => { it('should NOT return the background search menu item', () => { - const topNavLinks = setup(); + const appMenuConfig = setup(); - expect(topNavLinks.filter((obj) => obj.id === 'backgroundSearch')).toHaveLength(0); + const backgroundSearchItem = appMenuConfig.items!.find( + (item) => item.id === 'backgroundSearch' + ); + expect(backgroundSearchItem).toBeUndefined(); }); }); }); diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 2bf04469eb376..a326065324f25 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -70,7 +70,6 @@ "@kbn/no-data-page-plugin", "@kbn/global-search-plugin", "@kbn/resizable-layout", - "@kbn/unsaved-changes-badge", "@kbn/rule-data-utils", "@kbn/core-chrome-browser", "@kbn/core-plugins-server", diff --git a/tsconfig.base.json b/tsconfig.base.json index c11c3891f80b1..ef1367b82c295 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2396,8 +2396,6 @@ "@kbn/unified-tabs/*": ["src/platform/packages/shared/kbn-unified-tabs/*"], "@kbn/unified-tabs-examples-plugin": ["examples/unified_tabs_examples"], "@kbn/unified-tabs-examples-plugin/*": ["examples/unified_tabs_examples/*"], - "@kbn/unsaved-changes-badge": ["src/platform/packages/private/kbn-unsaved-changes-badge"], - "@kbn/unsaved-changes-badge/*": ["src/platform/packages/private/kbn-unsaved-changes-badge/*"], "@kbn/unsaved-changes-prompt": ["src/platform/packages/shared/kbn-unsaved-changes-prompt"], "@kbn/unsaved-changes-prompt/*": ["src/platform/packages/shared/kbn-unsaved-changes-prompt/*"], "@kbn/upgrade-assistant-pkg-common": ["x-pack/platform/packages/private/upgrade-assistant/common"], diff --git a/yarn.lock b/yarn.lock index 52496c99105cd..19b5fc9bdf5c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9304,10 +9304,6 @@ version "0.0.0" uid "" -"@kbn/unsaved-changes-badge@link:src/platform/packages/private/kbn-unsaved-changes-badge": - version "0.0.0" - uid "" - "@kbn/unsaved-changes-prompt@link:src/platform/packages/shared/kbn-unsaved-changes-prompt": version "0.0.0" uid "" From 79651e6caca6c6d571f4b024187956e52b3d1734 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 02:06:06 +0100 Subject: [PATCH 11/76] Remove context_awarness examples --- .../example/example_context.ts | 22 -- .../components/chart_with_custom_buttons.tsx | 262 ------------- .../components/index.ts | 10 - .../example_data_source_profile/index.ts | 10 - .../example_data_source_profile/profile.tsx | 368 ------------------ .../example/example_document_profile/index.ts | 10 - .../example_document_profile/profile.ts | 30 -- .../example/example_root_profile/index.ts | 13 - .../example/example_root_profile/profile.tsx | 167 -------- ...register_enabled_profile_providers.test.ts | 46 --- .../register_profile_providers.test.ts | 50 +-- .../register_profile_providers.ts | 10 - 12 files changed, 1 insertion(+), 997 deletions(-) delete mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_context.ts delete mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/chart_with_custom_buttons.tsx delete mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/index.ts delete mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/index.ts delete mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx delete mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/index.ts delete mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/profile.ts delete mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts delete mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_context.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_context.ts deleted file mode 100644 index e9475d61f1425..0000000000000 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_context.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { createContext, useContext } from 'react'; - -const exampleContext = createContext<{ - currentMessage: string | undefined; - setCurrentMessage: (message: string | undefined) => void; -}>({ - currentMessage: undefined, - setCurrentMessage: () => {}, -}); - -export const ExampleContextProvider = exampleContext.Provider; - -export const useExampleContext = () => useContext(exampleContext); diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/chart_with_custom_buttons.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/chart_with_custom_buttons.tsx deleted file mode 100644 index 8abca0666f483..0000000000000 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/chart_with_custom_buttons.tsx +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { ChartSectionProps } from '@kbn/unified-histogram/types'; -import type { UnifiedHistogramFetch$Arguments } from '@kbn/unified-histogram/types'; -import { UnifiedBreakdownFieldSelector } from '@kbn/unified-histogram'; -import type { LensEmbeddableInput } from '@kbn/lens-plugin/public'; -import type { DataViewField } from '@kbn/data-views-plugin/common'; -import { css } from '@emotion/react'; -import { - EuiButton, - EuiFlexGroup, - EuiFlexItem, - euiPaletteColorBlind, - useEuiTheme, -} from '@elastic/eui'; -import React, { useMemo, useState, useEffect, useCallback } from 'react'; -import type { ChartSectionConfigurationExtensionParams } from '../../../../types'; -import { - useCurrentTabAction, - useInternalStateDispatch, -} from '../../../../../application/main/state_management/redux'; -import { internalStateActions } from '../../../../../application/main/state_management/redux'; - -interface ChartWithCustomButtonsProps extends ChartSectionProps { - actions: ChartSectionConfigurationExtensionParams['actions']; -} - -export const ChartWithCustomButtons = ({ actions, ...props }: ChartWithCustomButtonsProps) => { - const { isComponentVisible, fetch$, fetchParams, onBrushEnd, renderToggleActions, services } = - props; - const { euiTheme } = useEuiTheme(); - const euiPalette = euiPaletteColorBlind(); - const { openInNewTab, updateESQLQuery } = actions; - - const dispatch = useInternalStateDispatch(); - const updateAppState = useCurrentTabAction(internalStateActions.updateAppState); - - const handleBreakdownFieldChange = useCallback( - (breakdownField: DataViewField | undefined) => { - dispatch(updateAppState({ appState: { breakdownField: breakdownField?.name } })); - }, - [dispatch, updateAppState] - ); - - const lensAttributes = useMemo(() => { - const { dataView, query, timeInterval } = fetchParams; - - if (!dataView.isTimeBased() || !dataView.timeFieldName) return null; - - const LAYER_ID = 'exampleHistogramLayer'; - const columns = { - date_column: { - dataType: 'date', - isBucketed: true, - label: dataView.timeFieldName, - operationType: 'date_histogram', - params: { interval: timeInterval || 'auto' }, - scale: 'interval', - sourceField: dataView.timeFieldName, - }, - count_column: { - dataType: 'number', - isBucketed: false, - label: 'Count of records', - operationType: 'count', - params: { format: { id: 'number', params: { decimals: 0 } } }, - scale: 'ratio', - sourceField: '___records___', - }, - }; - - return { - references: [ - { - id: dataView.id || '', - name: `indexpattern-datasource-layer-${LAYER_ID}`, - type: 'index-pattern', - }, - ], - state: { - adHocDataViews: {}, - datasourceStates: { - formBased: { - layers: { - [LAYER_ID]: { - columnOrder: ['date_column', 'count_column'], - columns, - indexPatternId: dataView.id, - }, - }, - }, - }, - filters: [], - internalReferences: [], - query: query || { language: 'kuery', query: '' }, - visualization: { - layers: [ - { - accessors: ['count_column'], - layerId: LAYER_ID, - layerType: 'data', - seriesType: 'bar_stacked', - xAccessor: 'date_column', - yConfig: [{ forAccessor: 'count_column', color: euiPalette[4] }], - }, - ], - legend: { isVisible: true, position: 'right' }, - preferredSeriesType: 'bar_stacked', - showCurrentTimeMarker: true, - valueLabels: 'hide', - }, - }, - title: 'Histogram', - visualizationType: 'lnsXY', - } as Parameters[0]['attributes']; - }, [euiPalette, fetchParams, services]); - - const [externalAttributes, setExternalAttributes] = useState< - Parameters[0]['attributes'] | null - >(null); - - useEffect(() => { - const subscription = fetch$.subscribe( - ({ lensVisServiceState }: UnifiedHistogramFetch$Arguments) => { - if (lensVisServiceState?.visContext?.attributes) { - setExternalAttributes(lensVisServiceState.visContext.attributes); - } - } - ); - return () => subscription.unsubscribe(); - }, [fetch$]); - - const handleBrushEnd: NonNullable = useCallback( - (data) => { - data.preventDefault(); - - if (onBrushEnd) { - onBrushEnd(data); - } else if (data.range.length >= 2) { - const [min, max] = data.range; - const from = new Date(min).toISOString(); - const to = new Date(max).toISOString(); - services.data.query.timefilter.timefilter.setTime({ - from, - to, - mode: 'absolute', - }); - } - }, - [onBrushEnd, services.data.query.timefilter.timefilter] - ); - - const onLoad = useCallback(() => {}, []); - - if (!isComponentVisible) return null; - - const finalAttributes = externalAttributes || lensAttributes; - - const chartCss = css` - flex-grow: 1; - margin-block: ${euiTheme.size.xs}; - min-height: 200px; - position: relative; - & > div { - height: 100%; - position: absolute; - width: 100%; - } - `; - - return ( - - - - {renderToggleActions()} - {fetchParams.breakdown && ( - - - - )} - {updateESQLQuery && ( - - updateESQLQuery('FROM my-example-logs | LIMIT 50')} - size="s" - > - Update ES|QL query - - - )} - {openInNewTab && ( - - - openInNewTab({ - query: { esql: 'FROM my-example-logs | LIMIT 100' }, - tabLabel: 'Example Logs Tab', - timeRange: { - from: 'now-1d', - to: 'now', - }, - }) - } - size="s" - > - Open new tab - - - )} - - - - {finalAttributes ? ( -
- -
- ) : ( - - Chart not available - - )} -
-
- ); -}; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/index.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/index.ts deleted file mode 100644 index 2467367755edb..0000000000000 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export * from './chart_with_custom_buttons'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/index.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/index.ts deleted file mode 100644 index 03d5412fb6692..0000000000000 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export { createExampleDataSourceProfileProvider } from './profile'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx deleted file mode 100644 index 9ff67e5044462..0000000000000 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx +++ /dev/null @@ -1,368 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { - EuiBadge, - EuiLink, - EuiFlyout, - EuiFlexGroup, - EuiSpacer, - EuiCodeBlock, - EuiTitle, - EuiButton, - EuiFlexItem, -} from '@elastic/eui'; -import type { RowControlColumn } from '@kbn/discover-utils'; -import { AppMenuActionId, AppMenuActionType, getFieldValue } from '@kbn/discover-utils'; -import type { DataViewField } from '@kbn/data-views-plugin/common'; -import { capitalize } from 'lodash'; -import React from 'react'; -import type { DataSourceProfileProvider } from '../../../profiles'; -import { ChartWithCustomButtons } from './components'; -import { DataSourceCategory } from '../../../profiles'; -import { useExampleContext } from '../example_context'; -import { extractIndexPatternFrom } from '../../extract_index_pattern_from'; - -export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvider<{ - formatRecord: (flattenedRecord: Record) => string; -}> => ({ - profileId: 'example-data-source-profile', - isExperimental: true, - profile: { - getCellRenderers: (prev) => (params) => ({ - ...prev(params), - 'log.level': (props) => { - const level = getFieldValue(props.row, 'log.level') as string; - - if (!level) { - return ( - ({ - color: euiTheme.colors.textSubdued, - })} - data-test-subj="exampleDataSourceProfileLogLevelEmpty" - > - (None) - - ); - } - - const levelMap: Record = { - info: 'primary', - debug: 'default', - error: 'danger', - }; - - return ( - - {capitalize(level)} - - ); - }, - message: function Message(props) { - const { currentMessage, setCurrentMessage } = useExampleContext(); - const message = getFieldValue(props.row, 'message') as string; - - return ( - setCurrentMessage(message)} - css={{ fontWeight: currentMessage === message ? 'bold' : undefined }} - data-test-subj="exampleDataSourceProfileMessage" - > - {message} - - ); - }, - }), - getDocViewer: - (prev, { context }) => - (params) => { - const { openInNewTab, updateESQLQuery } = params.actions; - const recordId = params.record.id; - const prevValue = prev(params); - - return { - title: `Record #${recordId}`, - docViewsRegistry: (registry) => { - registry.add({ - id: 'doc_view_example', - title: 'Example', - order: 0, - component: () => ( - <> - - - - -

Example doc view

-
- {(openInNewTab || updateESQLQuery) && ( - - - {updateESQLQuery && ( - { - updateESQLQuery('FROM my-example-logs | LIMIT 5'); - }} - data-test-subj="exampleDataSourceProfileDocViewUpdateEsqlQuery" - > - Update ES|QL query - - )} - {openInNewTab && ( - { - openInNewTab({ - tabLabel: 'My new tab', - query: { esql: 'FROM my-example-logs | LIMIT 5' }, - }); - }} - data-test-subj="exampleDataSourceProfileDocViewOpenNewTab" - > - Open new tab - - )} - - - )} -
- - {context.formatRecord(params.record.flattened)} - -
- - ), - }); - - return prevValue.docViewsRegistry(registry); - }, - }; - }, - /** - * The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu. - * The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation. - * And it supports opening custom flyouts and any other modals on the click. - * `getAppMenu` can be configured in both root and data source profiles. - * @param prev - */ - getAppMenu: (prev) => (params) => { - const prevValue = prev(params); - - // This is what is available via params: - // const { dataView, services, isEsqlMode, adHocDataViews, actions } = params; - - return { - appMenuRegistry: (registry) => { - // Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored. - - // Can be a on-click action, link or a submenu with an array of actions and horizontal rules - registry.registerCustomAction({ - id: 'example-custom-action', - type: AppMenuActionType.custom, - controlProps: { - label: 'Custom action', - testId: 'example-custom-action', - onClick: ({ onFinishAction }) => { - alert('Example Custom action clicked'); - onFinishAction(); // This allows to return focus back to the app menu DOM node - }, - }, - // In case of a submenu, you can add actions to it under `actions` - // actions: [ - // { - // id: 'example-custom-action-1-1', - // type: AppMenuActionType.custom, - // controlProps: { - // label: 'Custom action', - // onClick: ({ onFinishAction }) => { - // alert('Example Custom action clicked'); - // onFinishAction(); - // }, - // }, - // }, - // { - // id: 'example-custom-action-1-2', - // type: AppMenuActionType.submenuHorizontalRule - // }, - // ... - // ], - }); - - // This example shows how to add a custom action under the Alerts submenu - registry.registerCustomActionUnderSubmenu(AppMenuActionId.alerts, { - // It's also possible to override the submenu actions by using the same id - // as `AppMenuActionId.createRule` or `AppMenuActionId.manageRulesAndConnectors` - id: 'example-custom-action4', - type: AppMenuActionType.custom, - order: 101, - controlProps: { - label: 'Create SLO (Custom action)', - iconType: 'visGauge', - testId: 'example-custom-action-under-alerts', - onClick: ({ onFinishAction }) => { - // This is an example of a custom action that opens a flyout or any other custom modal. - // To do so, simply return a React element and call onFinishAction when you're done. - return ( - -
Example custom action clicked
-
- ); - }, - }, - }); - - // This submenu was defined in the root profile example_root_pofile/profile.tsx - // And we can still add actions to it from the data source profile here. - registry.registerCustomActionUnderSubmenu('example-custom-root-submenu', { - id: 'example-custom-action5', - type: AppMenuActionType.custom, - controlProps: { - label: 'Custom action (from Data Source profile)', - onClick: ({ onFinishAction }) => { - alert('Example Data source action under root submenu clicked'); - onFinishAction(); - }, - }, - }); - - return prevValue.appMenuRegistry(registry); - }, - }; - }, - getRowAdditionalLeadingControls: (prev) => (params) => { - const additionalControls = prev(params) || []; - - return [ - ...additionalControls, - ...['visBarVerticalStacked', 'heart', 'inspect'].map( - (iconType): RowControlColumn => ({ - id: `exampleControl_${iconType}`, - render: (Control, rowProps) => { - return ( - { - alert(`Example "${iconType}" control clicked. Row index: ${rowProps.rowIndex}`); - }} - /> - ); - }, - }) - ), - ]; - }, - getDefaultAppState: () => () => ({ - breakdownField: 'log.level', - columns: [ - { - name: '@timestamp', - width: 212, - }, - { - name: 'log.level', - width: 150, - }, - { - name: 'message', - }, - ], - rowHeight: 5, - }), - getAdditionalCellActions: (prev) => () => - [ - ...prev(), - { - id: 'example-data-source-action', - getDisplayName: () => 'Example data source action', - getIconType: () => 'plus', - execute: () => { - alert('Example data source action executed'); - }, - }, - { - id: 'another-example-data-source-action', - getDisplayName: () => 'Another example data source action', - getIconType: () => 'minus', - execute: () => { - alert('Another example data source action executed'); - }, - isCompatible: ({ field }) => field.name !== 'message', - }, - ], - getPaginationConfig: (prev) => () => ({ - ...prev(), - paginationMode: 'singlePage', - }), - /** - * The `getRecommendedFields` extension point allows profiles to define fields that should be surfaced - * as recommended in the field list sidebar. These fields appear in a dedicated "Recommended Fields" section. - * This is useful for highlighting important fields for specific data source types. - * @param prev - */ - getRecommendedFields: (prev) => () => { - // Define example recommended field names for the example logs data source - const exampleRecommendedFieldNames: Array = [ - 'log.level', - 'message', - 'service.name', - 'host.name', - ]; - - return { - ...prev(), - recommendedFields: exampleRecommendedFieldNames, - }; - }, - getChartSectionConfiguration: (prev) => (params) => { - return { - ...prev(params), - renderChartSection: (props) => ( - - ), - localStorageKeyPrefix: 'discover:exampleDataSource', - replaceDefaultChart: true, - }; - }, - }, - resolve: (params) => { - const indexPattern = extractIndexPatternFrom(params); - - if (indexPattern !== 'my-example-logs' && indexPattern !== 'my-example-logs,logstash*') { - return { isMatch: false }; - } - - return { - isMatch: true, - context: { - category: DataSourceCategory.Logs, - formatRecord: (record) => JSON.stringify(record, null, 2), - }, - }; - }, -}); diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/index.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/index.ts deleted file mode 100644 index cd27c9abe55f7..0000000000000 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export { createExampleDocumentProfileProvider } from './profile'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/profile.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/profile.ts deleted file mode 100644 index 5752db8fde17e..0000000000000 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/profile.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { getFieldValue } from '@kbn/discover-utils'; -import type { DocumentProfileProvider } from '../../../profiles'; -import { DocumentType } from '../../../profiles'; - -export const createExampleDocumentProfileProvider = (): DocumentProfileProvider => ({ - profileId: 'example-document-profile', - isExperimental: true, - profile: {}, - resolve: (params) => { - if (getFieldValue(params.record, 'data_stream.type') !== 'example') { - return { isMatch: false }; - } - - return { - isMatch: true, - context: { - type: DocumentType.Default, - }, - }; - }, -}); diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts deleted file mode 100644 index b286a7d8cdce0..0000000000000 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export { - createExampleRootProfileProvider, - createExampleSolutionViewRootProfileProvider, -} from './profile'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx deleted file mode 100644 index 627aebc6dfa31..0000000000000 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { - EuiBadge, - EuiCodeBlock, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, -} from '@elastic/eui'; -import { AppMenuActionType, getFieldValue } from '@kbn/discover-utils'; -import React, { useState } from 'react'; -import type { RootProfileProvider } from '../../../profiles'; -import { SolutionType } from '../../../profiles'; -import { ExampleContextProvider } from '../example_context'; - -export const createExampleRootProfileProvider = (): RootProfileProvider => ({ - profileId: 'example-root-profile', - isExperimental: true, - profile: { - getRenderAppWrapper, - getDefaultAdHocDataViews, - getCellRenderers: (prev) => (params) => ({ - ...prev(params), - '@timestamp': (props) => { - const timestamp = getFieldValue(props.row, '@timestamp') as string; - - return ( - - {timestamp} - - ); - }, - }), - /** - * The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu. - * The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation. - * And it supports opening custom flyouts and any other modals on the click. - * `getAppMenu` can be configured in both root and data source profiles. - * @param prev - */ - getAppMenu: (prev) => (params) => { - const prevValue = prev(params); - - // Check `params` for the available deps - - return { - appMenuRegistry: (registry) => { - // Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored. - - // Register a custom submenu action - registry.registerCustomAction({ - id: 'example-custom-root-submenu', - type: AppMenuActionType.custom, - label: 'Custom Submenu', - testId: 'example-custom-root-submenu', - actions: [ - { - id: 'example-custom-root-action11', - type: AppMenuActionType.custom, - controlProps: { - label: 'Custom action 11 (from Root profile)', - testId: 'example-custom-root-action11', - onClick: ({ onFinishAction }) => { - alert('Example Root Custom action 11 clicked'); - onFinishAction(); // This allows to close the popover and return focus back to the app menu DOM node - }, - }, - }, - { - id: 'example-custom-root-action12', - type: AppMenuActionType.custom, - controlProps: { - label: 'Custom action 12 (from Root profile)', - testId: 'example-custom-root-action12', - onClick: ({ onFinishAction }) => { - // This is an example of a custom action that opens a flyout or any other custom modal. - // To do so, simply return a React element and call onFinishAction when you're done. - return ( - -
Example custom action clicked
-
- ); - }, - }, - }, - ], - }); - - return prevValue.appMenuRegistry(registry); - }, - }; - }, - }, - resolve: (params) => { - if (params.solutionNavId != null) { - return { isMatch: false }; - } - - return { isMatch: true, context: { solutionType: SolutionType.Default } }; - }, -}); - -export const createExampleSolutionViewRootProfileProvider = (): RootProfileProvider => ({ - profileId: 'example-solution-view-root-profile', - isExperimental: true, - profile: { getRenderAppWrapper, getDefaultAdHocDataViews }, - resolve: (params) => ({ - isMatch: true, - context: { solutionType: params.solutionNavId as SolutionType }, - }), -}); - -const getRenderAppWrapper: RootProfileProvider['profile']['getRenderAppWrapper'] = - (PrevWrapper) => - ({ children }) => { - const [currentMessage, setCurrentMessage] = useState(undefined); - - return ( - - - {children} - {currentMessage && ( - setCurrentMessage(undefined)} - data-test-subj="exampleRootProfileFlyout" - > - - -

Inspect message

-
-
- - - {currentMessage} - - -
- )} -
-
- ); - }; - -const getDefaultAdHocDataViews: RootProfileProvider['profile']['getDefaultAdHocDataViews'] = - (prev) => () => - [ - ...prev(), - { - id: 'example-root-profile-ad-hoc-data-view', - name: 'Example profile data view', - title: 'my-example-*', - timeFieldName: '@timestamp', - }, - ]; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_enabled_profile_providers.test.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_enabled_profile_providers.test.ts index a447efb3575da..ce88009085392 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_enabled_profile_providers.test.ts +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_enabled_profile_providers.test.ts @@ -14,12 +14,9 @@ import { createContextAwarenessMocks, createProfileProviderSharedServicesMock, } from '../__mocks__'; -import { createExampleRootProfileProvider } from './example/example_root_profile'; import { registerEnabledProfileProviders } from './register_enabled_profile_providers'; import type { CellRenderersExtensionParams } from '../types'; -const exampleRootProfileProvider = createExampleRootProfileProvider(); - describe('registerEnabledProfileProviders', () => { beforeEach(() => { jest.clearAllMocks(); @@ -47,49 +44,6 @@ describe('registerEnabledProfileProviders', () => { }); }); - it('should not register experimental profile providers by default', async () => { - jest.spyOn(exampleRootProfileProvider.profile, 'getCellRenderers'); - const profileProviderServices = createProfileProviderSharedServicesMock(); - const { rootProfileServiceMock } = createContextAwarenessMocks({ - shouldRegisterProviders: false, - }); - registerEnabledProfileProviders({ - profileService: rootProfileServiceMock, - providers: [exampleRootProfileProvider], - enabledExperimentalProfileIds: [], - services: profileProviderServices, - }); - const context = await rootProfileServiceMock.resolve({ solutionNavId: null }); - const profile = rootProfileServiceMock.getProfile({ context }); - const baseImpl = () => ({}); - profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); - expect(exampleRootProfileProvider.profile.getCellRenderers).not.toHaveBeenCalled(); - expect(profile).toMatchObject({}); - }); - - it('should register experimental profile providers when enabled by config', async () => { - jest.spyOn(exampleRootProfileProvider.profile, 'getCellRenderers'); - const profileProviderServices = createProfileProviderSharedServicesMock(); - const { rootProfileServiceMock, rootProfileProviderMock } = createContextAwarenessMocks({ - shouldRegisterProviders: false, - }); - registerEnabledProfileProviders({ - profileService: rootProfileServiceMock, - providers: [exampleRootProfileProvider], - enabledExperimentalProfileIds: [exampleRootProfileProvider.profileId], - services: profileProviderServices, - }); - const context = await rootProfileServiceMock.resolve({ solutionNavId: null }); - const profile = rootProfileServiceMock.getProfile({ context }); - const baseImpl = () => ({}); - profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); - expect(exampleRootProfileProvider.profile.getCellRenderers).toHaveBeenCalledTimes(1); - expect(exampleRootProfileProvider.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, { - context, - }); - expect(rootProfileProviderMock.profile.getCellRenderers).not.toHaveBeenCalled(); - }); - it('should register restricted profile when product feature is available', async () => { const profileProviderServices = createProfileProviderSharedServicesMock(); const { rootProfileServiceMock, dataSourceProfileServiceMock, dataSourceProfileProviderMock } = diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts index c43c0cf5eb2fb..807d9645452ba 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts @@ -8,11 +8,7 @@ */ import { uniq } from 'lodash'; -import { createEsqlDataSource } from '../../../common/data_sources'; import { createContextAwarenessMocks, createProfileProviderSharedServicesMock } from '../__mocks__'; -import { createExampleRootProfileProvider } from './example/example_root_profile'; -import { createExampleDataSourceProfileProvider } from './example/example_data_source_profile/profile'; -import { createExampleDocumentProfileProvider } from './example/example_document_profile'; import { registerProfileProviders } from './register_profile_providers'; import type { BaseProfileProvider } from '../profile_service'; @@ -40,10 +36,6 @@ jest.mock('./register_enabled_profile_providers', () => { }; }); -const exampleRootProfileProvider = createExampleRootProfileProvider(); -const exampleDataSourceProfileProvider = createExampleDataSourceProfileProvider(); -const exampleDocumentProfileProvider = createExampleDocumentProfileProvider(); - describe('registerProfileProviders', () => { beforeEach(() => { mockAllCollectedProfiles = []; @@ -59,32 +51,10 @@ describe('registerProfileProviders', () => { rootProfileService: rootProfileServiceMock, dataSourceProfileService: dataSourceProfileServiceMock, documentProfileService: documentProfileServiceMock, - enabledExperimentalProfileIds: [ - exampleRootProfileProvider.profileId, - exampleDataSourceProfileProvider.profileId, - exampleDocumentProfileProvider.profileId, - ], + enabledExperimentalProfileIds: [], sharedServices: profileProviderServices, services: profileProviderServices, }); - const rootContext = await rootProfileServiceMock.resolve({ solutionNavId: null }); - const dataSourceContext = await dataSourceProfileServiceMock.resolve({ - rootContext, - dataSource: createEsqlDataSource(), - query: { esql: 'from my-example-logs' }, - }); - const documentContext = documentProfileServiceMock.resolve({ - rootContext, - dataSourceContext, - record: { - id: 'test', - flattened: { 'data_stream.type': 'example' }, - raw: {}, - }, - }); - expect(rootContext.profileId).toBe(exampleRootProfileProvider.profileId); - expect(dataSourceContext.profileId).toBe(exampleDataSourceProfileProvider.profileId); - expect(documentContext.profileId).toBe(exampleDocumentProfileProvider.profileId); }); it('should not register disabled experimental profile providers', async () => { @@ -101,24 +71,6 @@ describe('registerProfileProviders', () => { sharedServices: profileProviderServices, services: profileProviderServices, }); - const rootContext = await rootProfileServiceMock.resolve({ solutionNavId: null }); - const dataSourceContext = await dataSourceProfileServiceMock.resolve({ - rootContext, - dataSource: createEsqlDataSource(), - query: { esql: 'from my-example-logs' }, - }); - const documentContext = documentProfileServiceMock.resolve({ - rootContext, - dataSourceContext, - record: { - id: 'test', - flattened: { 'data_stream.type': 'example' }, - raw: {}, - }, - }); - expect(rootContext.profileId).not.toBe(exampleRootProfileProvider.profileId); - expect(dataSourceContext.profileId).not.toBe(exampleDataSourceProfileProvider.profileId); - expect(documentContext.profileId).not.toBe(exampleDocumentProfileProvider.profileId); }); it('all profile ids should be unique', async () => { diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.ts index 8b1b4da9842a1..2f371fc26fc02 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.ts +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.ts @@ -17,12 +17,6 @@ import { createClassicNavRootProfileProvider } from './common/classic_nav_root_p import { createDeprecationLogsDataSourceProfileProvider } from './common/deprecation_logs_data_source_profile'; import { createPatternsDataSourceProfileProvider } from './common/patterns_data_source_profile'; import { registerEnabledProfileProviders } from './register_enabled_profile_providers'; -import { createExampleDataSourceProfileProvider } from './example/example_data_source_profile/profile'; -import { createExampleDocumentProfileProvider } from './example/example_document_profile'; -import { - createExampleRootProfileProvider, - createExampleSolutionViewRootProfileProvider, -} from './example/example_root_profile'; import { createObservabilityLogsDataSourceProfileProviders } from './observability/logs_data_source_profile'; import { createObservabilityDocumentProfileProviders } from './observability/observability_profile_providers'; import { createObservabilityRootProfileProvider } from './observability/observability_root_profile/profile'; @@ -105,8 +99,6 @@ export const registerProfileProviders = ({ * @returns An array of available root profile providers */ const createRootProfileProviders = (providerServices: ProfileProviderServices) => [ - createExampleRootProfileProvider(), - createExampleSolutionViewRootProfileProvider(), createClassicNavRootProfileProvider(providerServices), createSecurityRootProfileProvider(providerServices), createObservabilityRootProfileProvider(providerServices), @@ -118,7 +110,6 @@ const createRootProfileProviders = (providerServices: ProfileProviderServices) = * @returns An array of available data source profile providers */ const createDataSourceProfileProviders = (providerServices: ProfileProviderServices) => [ - createExampleDataSourceProfileProvider(), createPatternsDataSourceProfileProvider(providerServices), createDeprecationLogsDataSourceProfileProvider(), ...createObservabilityLogsDataSourceProfileProviders(providerServices), @@ -132,7 +123,6 @@ const createDataSourceProfileProviders = (providerServices: ProfileProviderServi * @returns An array of available document profile providers */ const createDocumentProfileProviders = (providerServices: ProfileProviderServices) => [ - createExampleDocumentProfileProvider(), createSecurityDocumentProfileProvider(providerServices), ...createObservabilityDocumentProfileProviders(providerServices), ]; From a2116d47ddaf30f12d37cba3f3ce53c849f272fc Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 15 Jan 2026 01:20:49 +0000 Subject: [PATCH 12/76] Changes from node scripts/lint_ts_projects --fix --- src/platform/packages/shared/kbn-discover-utils/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/tsconfig.json b/src/platform/packages/shared/kbn-discover-utils/tsconfig.json index 9fd84fe27c561..d6d53a936fc3a 100644 --- a/src/platform/packages/shared/kbn-discover-utils/tsconfig.json +++ b/src/platform/packages/shared/kbn-discover-utils/tsconfig.json @@ -27,6 +27,7 @@ "@kbn/apm-sources-access-plugin", "@kbn/data-plugin", "@kbn/core-ui-settings-browser", - "@kbn/apm-types" + "@kbn/apm-types", + "@kbn/core-chrome-app-menu-components" ] } From ead30f3f001a446d4afb0bc0565be7453f84e245 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 15 Jan 2026 01:32:02 +0000 Subject: [PATCH 13/76] Changes from node scripts/generate codeowners --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 03e80c3dc4b80..0173469ee46ee 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -399,7 +399,6 @@ src/platform/packages/private/kbn-tinymath @elastic/kibana-visualizations src/platform/packages/private/kbn-transpose-utils @elastic/kibana-visualizations src/platform/packages/private/kbn-ui-shared-deps-npm @elastic/kibana-operations src/platform/packages/private/kbn-ui-shared-deps-src @elastic/kibana-operations -src/platform/packages/private/kbn-unsaved-changes-badge @elastic/kibana-data-discovery src/platform/packages/private/kbn-validate-oas @elastic/kibana-core src/platform/packages/private/opentelemetry/kbn-metrics @elastic/kibana-core @elastic/stack-monitoring src/platform/packages/private/opentelemetry/kbn-metrics-config @elastic/kibana-core From 2bb2253363bf8a3506101cfcc4e8bfecffc771d9 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 15 Jan 2026 01:32:08 +0000 Subject: [PATCH 14/76] Changes from node scripts/regenerate_moon_projects.js --update --- src/platform/packages/shared/kbn-discover-utils/moon.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/packages/shared/kbn-discover-utils/moon.yml b/src/platform/packages/shared/kbn-discover-utils/moon.yml index 9d5594fa37e5b..cd7b2176c623f 100644 --- a/src/platform/packages/shared/kbn-discover-utils/moon.yml +++ b/src/platform/packages/shared/kbn-discover-utils/moon.yml @@ -33,6 +33,7 @@ dependsOn: - '@kbn/data-plugin' - '@kbn/core-ui-settings-browser' - '@kbn/apm-types' + - '@kbn/core-chrome-app-menu-components' tags: - shared-common - package From 36b05b02a30b7c259a29ef28daeab6d1f0cb1180 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 02:49:36 +0100 Subject: [PATCH 15/76] Fix types --- .../top_nav/app_menu_actions/get_share.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 8bbd1c92b99ad..25d4e2291a981 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -16,6 +16,7 @@ import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import type { AppMenuItemType, AppMenuPopoverItem } from '@kbn/core-chrome-app-menu-components'; import type { ShowShareMenuOptions } from '@kbn/share-plugin/public'; import type { IntlShape } from '@kbn/i18n-react'; +import type { ShareActionIntents } from '@kbn/share-plugin/public/types'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; import type { DataTotalHitsMsg } from '../../../state_management/discover_data_state_container'; import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data'; @@ -200,8 +201,11 @@ const getExportItems = ( }; const exportItems: AppMenuPopoverItem[] = exportIntegrations - .filter((item) => item.shareType === 'integration') - .map((item) => ({ + .filter( + (item: ShareActionIntents): item is typeof item & { shareType: 'integration'; id: string } => + item.shareType === 'integration' + ) + .map((item: ShareActionIntents & { shareType: 'integration'; id: string }) => ({ ...mapIntegrationToMetaData(item.id), id: item.id, run: async () => { @@ -213,18 +217,22 @@ const getExportItems = ( const derivativeItems: AppMenuPopoverItem[] = exportDerivatives .filter( - (item): item is typeof item & { shareType: 'integration'; id: string } => + ( + item: ShareActionIntents + ): item is typeof item & { shareType: 'integration'; id: string; groupId: string } => item.shareType === 'integration' && item.groupId === 'exportDerivatives' ) - .map((item) => ({ - ...mapIntegrationToMetaData(item.id), - id: item.id, - run: async () => { - const shareOptions = await buildShareOptions(buildShareOptionsParams); - const handler = await services.share?.getExportDerivativeHandler(shareOptions, item.id); - await handler?.(); - }, - })); + .map( + (item: ShareActionIntents & { shareType: 'integration'; id: string; groupId: string }) => ({ + ...mapIntegrationToMetaData(item.id), + id: item.id, + run: async () => { + const shareOptions = await buildShareOptions(buildShareOptionsParams); + const handler = await services.share?.getExportDerivativeHandler(shareOptions, item.id); + await handler?.(); + }, + }) + ); return [...exportItems, ...derivativeItems]; }; From 56dd091fad80ee0f3bd5e5760e242f1be5340203 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 03:24:26 +0100 Subject: [PATCH 16/76] Remove dead i18n --- .../private/translations/translations/de-DE.json | 12 ------------ .../private/translations/translations/fr-FR.json | 12 ------------ .../private/translations/translations/ja-JP.json | 12 ------------ .../private/translations/translations/zh-CN.json | 12 ------------ 4 files changed, 48 deletions(-) diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index e909686bc745f..09015e2969c0d 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -2514,7 +2514,6 @@ "discover.invalidFiltersWarnToast.description": "Die Datenquellen-ID-Referenzen in einigen der angewendeten Filter weichen von der aktuellen Datenquelle ab.", "discover.invalidFiltersWarnToast.title": "Unterschiedliche Index-Referenzen", "discover.loadingDocuments": "Dokumente werden geladen", - "discover.localMenu.alertsDescription": "Alerts", "discover.localMenu.esqlTooltipLabel": "ES|QL ist die leistungsstarke neue Pipe-basierte Abfragesprache von Elastic.", "discover.localMenu.fallbackReportTitle": "Discover-Sitzung ohne Titel", "discover.localMenu.inspectTitle": "Inspizieren", @@ -2522,11 +2521,8 @@ "discover.localMenu.localMenu.newDiscoverSessionTitle": "Neue Sitzung", "discover.localMenu.mustCopyOnSave": "Elastic verwaltet diese Discover-Sitzung. Speichern Sie alle Änderungen in einer neuen Discover-Sitzung.", "discover.localMenu.openDiscoverSessionTitle": "Sitzung öffnen", - "discover.localMenu.openInspectorForSearchDescription": "Inspector zum Suchen öffnen", "discover.localMenu.saveSaveSearchObjectType": "Discover-Sitzung", - "discover.localMenu.saveSearchDescription": "Sitzung speichern", "discover.localMenu.saveTitle": "Speichern", - "discover.localMenu.shareSearchDescription": "Teilen Sie die Discover-Sitzung", "discover.localMenu.shareTitle": "Teilen", "discover.localMenu.switchToClassicTitle": "Zu Classic wechseln", "discover.localMenu.switchToClassicTooltipLabel": "Wechseln Sie zur KQL- oder Lucene-Syntax.", @@ -7948,14 +7944,6 @@ "kql.switchLanguage.buttonText": "Schaltfläche zum Umschalten der Sprache.", "unifiedSearch.triggers.updateFilterReferencesTrigger": "Filterreferenzen aktualisieren", "unifiedSearch.triggers.updateFilterReferencesTriggerDescription": "Filterreferenzen aktualisieren", - "unsavedChangesBadge.contextMenu.openButton": "Verfügbare Aktionen anzeigen", - "unsavedChangesBadge.contextMenu.revertChangesButton": "Änderungen rückgängig machen", - "unsavedChangesBadge.contextMenu.revertingChangesButtonStatus": "Änderungen werden rückgängig gemacht...", - "unsavedChangesBadge.contextMenu.saveChangesAsButton": "Speichern unter", - "unsavedChangesBadge.contextMenu.saveChangesButton": "Speichern", - "unsavedChangesBadge.contextMenu.savingChangesAsButtonStatus": "Speichern unter...", - "unsavedChangesBadge.contextMenu.savingChangesButtonStatus": "Speichern...", - "unsavedChangesBadge.unsavedChangesTitle": "Nicht gespeicherte Änderungen", "unsavedChangesPrompt.defaultModalCancel": "Bearbeiten Sie weiter", "unsavedChangesPrompt.defaultModalConfirm": "Seite verlassen", "unsavedChangesPrompt.defaultModalText": "Die Daten gehen verloren, wenn Sie diese Seite verlassen, ohne die Änderungen zu speichern.", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 7bb0eaf1a0692..b913ae64a4b64 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -2533,7 +2533,6 @@ "discover.invalidFiltersWarnToast.description": "Les références d'ID de la vue de données dans certains filtres appliqués diffèrent de la vue de données actuelle.", "discover.invalidFiltersWarnToast.title": "Références d'index différentes", "discover.loadingDocuments": "Chargement des documents", - "discover.localMenu.alertsDescription": "Alertes", "discover.localMenu.esqlTooltipLabel": "ES|QL est le nouveau langage de requête canalisé puissant d'Elastic.", "discover.localMenu.fallbackReportTitle": "Session Discover sans titre", "discover.localMenu.inspectTitle": "Inspecter", @@ -2541,11 +2540,8 @@ "discover.localMenu.localMenu.newDiscoverSessionTitle": "Nouvelle session", "discover.localMenu.mustCopyOnSave": "Elastic gère cette session Discover. Enregistrez les modifications dans une nouvelle session Discover.", "discover.localMenu.openDiscoverSessionTitle": "Ouvrir la session", - "discover.localMenu.openInspectorForSearchDescription": "Ouvrir l'inspecteur de recherche", "discover.localMenu.saveSaveSearchObjectType": "Session Discover", - "discover.localMenu.saveSearchDescription": "Enregistrer la session", "discover.localMenu.saveTitle": "Enregistrer", - "discover.localMenu.shareSearchDescription": "Partager la session Discover", "discover.localMenu.shareTitle": "Partager", "discover.localMenu.switchToClassicTitle": "Basculer vers le classique", "discover.localMenu.switchToClassicTooltipLabel": "Passez à la syntaxe KQL ou Lucene.", @@ -8097,14 +8093,6 @@ "kql.switchLanguage.buttonText": "Bouton de changement de langue.", "unifiedSearch.triggers.updateFilterReferencesTrigger": "Mettre à jour les références de filtre", "unifiedSearch.triggers.updateFilterReferencesTriggerDescription": "Mettre à jour les références de filtre", - "unsavedChangesBadge.contextMenu.openButton": "Afficher les actions disponibles", - "unsavedChangesBadge.contextMenu.revertChangesButton": "Restaurer les modifications", - "unsavedChangesBadge.contextMenu.revertingChangesButtonStatus": "Annuler les modifications", - "unsavedChangesBadge.contextMenu.saveChangesAsButton": "Enregistrer sous", - "unsavedChangesBadge.contextMenu.saveChangesButton": "Enregistrer", - "unsavedChangesBadge.contextMenu.savingChangesAsButtonStatus": "Enregistrer sous...", - "unsavedChangesBadge.contextMenu.savingChangesButtonStatus": "Enregistrement en cours...", - "unsavedChangesBadge.unsavedChangesTitle": "Modifications non enregistrées", "unsavedChangesPrompt.defaultModalCancel": "Continuer la modification", "unsavedChangesPrompt.defaultModalConfirm": "Quitter la page", "unsavedChangesPrompt.defaultModalText": "Les données seront perdues si vous quittez cette page sans enregistrer les modifications.", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 40842008e5c9c..ca942d941497d 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -2533,7 +2533,6 @@ "discover.invalidFiltersWarnToast.description": "一部の適用されたフィルターのデータビューID参照は、現在のデータビューとは異なります。", "discover.invalidFiltersWarnToast.title": "別のインデックス参照", "discover.loadingDocuments": "ドキュメントを読み込み中", - "discover.localMenu.alertsDescription": "アラート", "discover.localMenu.esqlTooltipLabel": "ES|QLはElasticの強力な新しいパイプクエリ言語です。", "discover.localMenu.fallbackReportTitle": "無題のDiscoverセッション", "discover.localMenu.inspectTitle": "検査", @@ -2541,11 +2540,8 @@ "discover.localMenu.localMenu.newDiscoverSessionTitle": "新しいセッション", "discover.localMenu.mustCopyOnSave": "ElasticはこのDiscoverセッションを管理します。変更を新しいDiscoverセッションに保存します。", "discover.localMenu.openDiscoverSessionTitle": "セッションを開く", - "discover.localMenu.openInspectorForSearchDescription": "検索用にインスペクターを開きます", "discover.localMenu.saveSaveSearchObjectType": "Discoverセッション", - "discover.localMenu.saveSearchDescription": "セッションの保存", "discover.localMenu.saveTitle": "保存", - "discover.localMenu.shareSearchDescription": "Discoverセッションを共有", "discover.localMenu.shareTitle": "共有", "discover.localMenu.switchToClassicTitle": "クラシックに切り替える", "discover.localMenu.switchToClassicTooltipLabel": "KQLまたはLucene構文に切り替えます。", @@ -8108,14 +8104,6 @@ "kql.switchLanguage.buttonText": "言語の切り替えボタン。", "unifiedSearch.triggers.updateFilterReferencesTrigger": "フィルター参照を更新", "unifiedSearch.triggers.updateFilterReferencesTriggerDescription": "フィルター参照を更新", - "unsavedChangesBadge.contextMenu.openButton": "使用可能なアクションを表示", - "unsavedChangesBadge.contextMenu.revertChangesButton": "変更を元に戻す", - "unsavedChangesBadge.contextMenu.revertingChangesButtonStatus": "変更を元に戻しています...", - "unsavedChangesBadge.contextMenu.saveChangesAsButton": "名前を付けて保存", - "unsavedChangesBadge.contextMenu.saveChangesButton": "保存", - "unsavedChangesBadge.contextMenu.savingChangesAsButtonStatus": "名前を付けて保存中…", - "unsavedChangesBadge.contextMenu.savingChangesButtonStatus": "保存中...", - "unsavedChangesBadge.unsavedChangesTitle": "保存されていない変更", "unsavedChangesPrompt.defaultModalCancel": "編集を続行", "unsavedChangesPrompt.defaultModalConfirm": "ページから移動", "unsavedChangesPrompt.defaultModalText": "変更を保存せずに、このページから移動すると、データが失われます。", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 8cef287b31751..5f0663bd6a6c4 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -2526,7 +2526,6 @@ "discover.invalidFiltersWarnToast.description": "某些应用的筛选中的数据视图 ID 引用与当前数据视图不同。", "discover.invalidFiltersWarnToast.title": "不同的索引引用", "discover.loadingDocuments": "正在加载文档", - "discover.localMenu.alertsDescription": "告警", "discover.localMenu.esqlTooltipLabel": "ES|QL 是 Elastic 支持的功能强大的全新管道查询语言。", "discover.localMenu.fallbackReportTitle": "未命名 Discover 会话", "discover.localMenu.inspectTitle": "检查", @@ -2534,11 +2533,8 @@ "discover.localMenu.localMenu.newDiscoverSessionTitle": "新会话", "discover.localMenu.mustCopyOnSave": "Elastic 管理此 Discover 会话。将任何更改保存到新 Discover 会话。", "discover.localMenu.openDiscoverSessionTitle": "打开会话", - "discover.localMenu.openInspectorForSearchDescription": "打开 Inspector 以进行搜索", "discover.localMenu.saveSaveSearchObjectType": "Discover 会话", - "discover.localMenu.saveSearchDescription": "保存会话", "discover.localMenu.saveTitle": "保存", - "discover.localMenu.shareSearchDescription": "共享 Discover 会话", "discover.localMenu.shareTitle": "共享", "discover.localMenu.switchToClassicTitle": "切换到经典模式", "discover.localMenu.switchToClassicTooltipLabel": "切换到 KQL 或 Lucene 语法。", @@ -8098,14 +8094,6 @@ "kql.switchLanguage.buttonText": "切换语言按钮。", "unifiedSearch.triggers.updateFilterReferencesTrigger": "更新筛选参考", "unifiedSearch.triggers.updateFilterReferencesTriggerDescription": "更新筛选参考", - "unsavedChangesBadge.contextMenu.openButton": "查看可用操作", - "unsavedChangesBadge.contextMenu.revertChangesButton": "恢复更改", - "unsavedChangesBadge.contextMenu.revertingChangesButtonStatus": "正在恢复更改......", - "unsavedChangesBadge.contextMenu.saveChangesAsButton": "另存为", - "unsavedChangesBadge.contextMenu.saveChangesButton": "保存", - "unsavedChangesBadge.contextMenu.savingChangesAsButtonStatus": "正另存为......", - "unsavedChangesBadge.contextMenu.savingChangesButtonStatus": "正在保存......", - "unsavedChangesBadge.unsavedChangesTitle": "未保存的更改", "unsavedChangesPrompt.defaultModalCancel": "继续编辑", "unsavedChangesPrompt.defaultModalConfirm": "离开页面", "unsavedChangesPrompt.defaultModalText": "如果离开此页面而不保存更改,数据将会丢失。", From d76c396f64d40ee27df6d404f76ddc4732fcc8e9 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 13:02:23 +0100 Subject: [PATCH 17/76] Fix tests --- .../src/components/app_menu_popover.tsx | 22 +++++- .../src/utils.test.tsx | 76 ++++++++++++++----- .../src/utils.tsx | 18 +++-- .../top_nav/app_menu_actions/get_share.tsx | 1 + .../components/top_nav/get_top_nav_badges.tsx | 1 - .../components/top_nav/use_top_nav_links.tsx | 5 +- .../apps/discover/tabs2/_unsaved_changes.ts | 28 +++---- .../apps/discover/tabs3/_time_range.ts | 22 +++--- .../functional/page_objects/discover_page.ts | 21 +++-- .../apps/discover/group1/reporting.ts | 2 - .../functional/page_objects/reporting_page.ts | 1 + 11 files changed, 127 insertions(+), 70 deletions(-) diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.tsx index 506c52ea33a40..fb1abc1f258cb 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useMemo, type ReactElement } from 'react'; +import React, { useMemo, useState, type ReactElement } from 'react'; import type { PopoverAnchorPosition } from '@elastic/eui'; import { EuiContextMenu, EuiPopover, EuiToolTip } from '@elastic/eui'; import { getPopoverPanels, getTooltip } from '../utils'; @@ -46,7 +46,9 @@ export const AppMenuPopover = ({ onClose, onCloseOverflowButton, }: AppMenuContextMenuProps) => { - const panels = useMemo( + const [activePanelId, setActivePanelId] = useState('0'); + + const { panels, panelIdToTestId } = useMemo( () => getPopoverPanels({ items, @@ -62,6 +64,14 @@ export const AppMenuPopover = ({ return null; } + /** + * Determine the active test ID for the popover panel. + * EuiContextMenuPanelItemDescriptor does not support data-test-subj directly, + * so we map panel IDs to test IDs when creating the panels. + * TODO: Remove this implementation if EUI fix is provided: https://github.com/elastic/eui/issues/9321 + */ + const activeTestId = panelIdToTestId[activePanelId] || popoverTestId || 'app-menu-popover'; + const { content, title } = getTooltip({ tooltipContent, tooltipTitle }); const showTooltip = Boolean(content || title); @@ -85,10 +95,14 @@ export const AppMenuPopover = ({ width: popoverWidth, }} panelProps={{ - 'data-test-subj': popoverTestId || 'app-menu-popover', + 'data-test-subj': activeTestId, }} > - + setActivePanelId(String(panelId))} + /> ); }; diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.test.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.test.tsx index ae10ec45d7362..ab167813d3da5 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.test.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.test.tsx @@ -379,11 +379,11 @@ describe('utils', () => { { id: '2', label: 'Item 2', run: jest.fn(), order: 2 }, ]; - const result = getPopoverPanels({ items }); + const { panels } = getPopoverPanels({ items }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe(0); - expect(result[0].items).toHaveLength(2); + expect(panels).toHaveLength(1); + expect(panels[0].id).toBe(0); + expect(panels[0].items).toHaveLength(2); }); it('should create nested panels for items with sub-items', () => { @@ -396,11 +396,11 @@ describe('utils', () => { }, ]; - const result = getPopoverPanels({ items }); + const { panels } = getPopoverPanels({ items }); - expect(result).toHaveLength(2); - const mainPanel = result.find((p) => p.id === 0); - const childPanel = result.find((p) => p.id === 1); + expect(panels).toHaveLength(2); + const mainPanel = panels.find((p) => p.id === 0); + const childPanel = panels.find((p) => p.id === 1); expect(mainPanel).toBeDefined(); expect(childPanel).toBeDefined(); @@ -412,8 +412,8 @@ describe('utils', () => { { id: '1', label: 'Item 1', run: jest.fn(), order: 1, separator: 'above' }, ]; - const result = getPopoverPanels({ items }); - const panelItems = result[0].items as Array<{ isSeparator?: boolean; key?: string }>; + const { panels } = getPopoverPanels({ items }); + const panelItems = panels[0].items as Array<{ isSeparator?: boolean; key?: string }>; expect(panelItems[0].isSeparator).toBe(true); expect(panelItems[0].key).toBe('separator-1'); @@ -424,8 +424,8 @@ describe('utils', () => { { id: '1', label: 'Item 1', run: jest.fn(), order: 1, separator: 'below' }, ]; - const result = getPopoverPanels({ items }); - const panelItems = result[0].items as Array<{ isSeparator?: boolean; key?: string }>; + const { panels } = getPopoverPanels({ items }); + const panelItems = panels[0].items as Array<{ isSeparator?: boolean; key?: string }>; expect(panelItems[1].isSeparator).toBe(true); expect(panelItems[1].key).toBe('separator-1'); @@ -434,12 +434,12 @@ describe('utils', () => { it('should append action items to main panel when provided', () => { const items: AppMenuPopoverItem[] = [{ id: '1', label: 'Item 1', run: jest.fn(), order: 1 }]; - const result = getPopoverPanels({ + const { panels } = getPopoverPanels({ items, primaryActionItem: { id: 'save', label: 'Save', run: jest.fn(), iconType: 'save' }, }); - const mainPanel = result[0]; + const mainPanel = panels[0]; const panelItems = mainPanel.items as Array<{ key?: string; isSeparator?: boolean }>; expect(panelItems).toHaveLength(3); @@ -450,9 +450,9 @@ describe('utils', () => { it('should use custom startPanelId', () => { const items: AppMenuPopoverItem[] = [{ id: '1', label: 'Item 1', run: jest.fn(), order: 1 }]; - const result = getPopoverPanels({ items, startPanelId: 10 }); + const { panels } = getPopoverPanels({ items, startPanelId: 10 }); - expect(result[0].id).toBe(10); + expect(panels[0].id).toBe(10); }); it('should handle deeply nested items', () => { @@ -472,9 +472,49 @@ describe('utils', () => { }, ]; - const result = getPopoverPanels({ items }); + const { panels } = getPopoverPanels({ items }); + + expect(panels).toHaveLength(3); + }); + + it('should create panelIdToTestId mapping for items with popoverTestId', () => { + const items: AppMenuPopoverItem[] = [ + { + id: '1', + label: 'Export', + order: 1, + popoverTestId: 'exportPopoverPanel', + items: [ + { + id: '1-1', + label: 'PDF', + run: jest.fn(), + order: 1, + }, + ], + }, + { + id: '2', + label: 'Share', + order: 2, + popoverTestId: 'sharePopoverPanel', + items: [ + { + id: '2-1', + label: 'Link', + run: jest.fn(), + order: 1, + }, + ], + }, + ]; + + const { panels, panelIdToTestId } = getPopoverPanels({ items }); - expect(result).toHaveLength(3); + expect(panels).toHaveLength(3); + expect(panelIdToTestId['1']).toBe('exportPopoverPanel'); + expect(panelIdToTestId['2']).toBe('sharePopoverPanel'); + expect(panelIdToTestId['0']).toBeUndefined(); // Main panel has no test ID }); }); diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx index 1327c9ab6f842..e76267dc28a4c 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx @@ -222,18 +222,24 @@ export const getPopoverPanels = ({ startPanelId?: number; onClose?: () => void; onCloseOverflowButton?: () => void; -}): EuiContextMenuPanelDescriptor[] => { +}): { panels: EuiContextMenuPanelDescriptor[]; panelIdToTestId: Record } => { const panels: EuiContextMenuPanelDescriptor[] = []; + const panelIdToTestId: Record = {}; const hasActionItems = Boolean(primaryActionItem || secondaryActionItem); let currentPanelId = startPanelId; const processItems = ( itemsToProcess: AppMenuPopoverItem[], panelId: number, - parentTitle?: string + parentTitle?: string, + parentPopoverTestId?: string ) => { const panelItems: EuiContextMenuPanelItemDescriptor[] = []; + if (parentPopoverTestId) { + panelIdToTestId[String(panelId)] = parentPopoverTestId; + } + itemsToProcess.forEach((item) => { if (item.separator === 'above') { panelItems.push(createSeparatorItem(`separator-${item.id}`)); @@ -243,7 +249,7 @@ export const getPopoverPanels = ({ currentPanelId++; const childPanelId = currentPanelId; - processItems(item.items, childPanelId, item.label); + processItems(item.items, childPanelId, item.label, item.popoverTestId); panelItems.push( mapAppMenuItemToPanelItem(item, childPanelId, onClose, onCloseOverflowButton) ); @@ -272,7 +278,7 @@ export const getPopoverPanels = ({ if (hasActionItems) { const mainPanel = panels.find((panel) => panel.id === startPanelId); - if (!mainPanel) return panels; + if (!mainPanel) return { panels, panelIdToTestId }; const actionItems: EuiContextMenuPanelItemDescriptor[] = getPopoverActionItems({ primaryActionItem, @@ -282,10 +288,10 @@ export const getPopoverPanels = ({ mainPanel.items = [...(mainPanel.items as EuiContextMenuPanelItemDescriptor[]), ...actionItems]; - return panels; + return { panels, panelIdToTestId }; } - return panels; + return { panels, panelIdToTestId }; }; /** diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 25d4e2291a981..88856ab4c6999 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -313,6 +313,7 @@ export const getShareAppMenuItem = ({ iconType: 'exportAction', testId: 'exportTopNavButton', items: exportItems, + popoverTestId: 'exportPopoverPanel', }); } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx index 818b3f8c6656a..06b8186f2ccfc 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx @@ -31,7 +31,6 @@ export const getTopNavBadges = ({ const isManaged = stateContainer.savedSearchState.getState().managed; - // Show solutions view badge if spaces is enabled and not on mobile if (services.spaces && !isMobile) { entries.push({ badgeText: i18n.translate('discover.topNav.solutionViewTitle', { diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index d62d5b44555fd..4194289a5f6e9 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -227,10 +227,8 @@ export const useTopNavLinks = ({ const appMenuRegistry = useMemo(() => { const newAppMenuRegistry = new AppMenuRegistry(); - // Register all base items to the registry newAppMenuRegistry.registerItems(appMenuItems); - // Add ESQL switch item if (services.uiSettings.get(ENABLE_ESQL)) { newAppMenuRegistry.registerItem({ id: 'esql', @@ -287,6 +285,7 @@ export const useTopNavLinks = ({ }); }, popoverWidth: 150, + popoverTestId: 'discoverSaveButtonPopover', splitButtonProps: { showNotificationIndicator: hasUnsavedChanges, notifcationIndicatorTooltipContent: hasUnsavedChanges @@ -333,7 +332,7 @@ export const useTopNavLinks = ({ defaultMessage: 'Reset changes', }), iconType: 'editorUndo', - testId: 'discardChangesMenuItem', + testId: 'revertUnsavedChangesButton', disableButton: !hasUnsavedChanges, }, ], diff --git a/src/platform/test/functional/apps/discover/tabs2/_unsaved_changes.ts b/src/platform/test/functional/apps/discover/tabs2/_unsaved_changes.ts index a2d8d28db595f..072930705988b 100644 --- a/src/platform/test/functional/apps/discover/tabs2/_unsaved_changes.ts +++ b/src/platform/test/functional/apps/discover/tabs2/_unsaved_changes.ts @@ -37,7 +37,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab?.index)).to.be(true); - expect(await discover.hasUnsavedChangesBadge()).to.be(true); + expect(await discover.hasUnsavedChangesIndicator()).to.be(true); }); it('clears unsaved changes badge on session save', async () => { @@ -54,13 +54,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab?.index)).to.be(true); - expect(await discover.hasUnsavedChangesBadge()).to.be(true); + expect(await discover.hasUnsavedChangesIndicator()).to.be(true); await discover.saveSearch(SEARCH_NAME); await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab?.index)).to.be(false); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); }); it('reverts unsaved changes in all tabs after clicking revert changes button', async () => { @@ -81,7 +81,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab2?.index)).to.be(true); - expect(await discover.hasUnsavedChangesBadge()).to.be(true); + expect(await discover.hasUnsavedChangesIndicator()).to.be(true); await unifiedTabs.selectTab(0); await queryBar.setQuery(QUERY1); @@ -89,14 +89,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab1?.index)).to.be(true); - expect(await discover.hasUnsavedChangesBadge()).to.be(true); + expect(await discover.hasUnsavedChangesIndicator()).to.be(true); await discover.revertUnsavedChanges(); await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab1?.index)).to.be(false); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab2?.index)).to.be(false); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); }); it('persists unsaved state for modified tabs across a refresh and clears it upon saving', async () => { @@ -123,7 +123,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab1?.index)).to.be(true); - expect(await discover.hasUnsavedChangesBadge()).to.be(true); + expect(await discover.hasUnsavedChangesIndicator()).to.be(true); await unifiedTabs.selectTab(1); await queryBar.setQuery(QUERY2); @@ -131,7 +131,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab2?.index)).to.be(true); - expect(await discover.hasUnsavedChangesBadge()).to.be(true); + expect(await discover.hasUnsavedChangesIndicator()).to.be(true); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab3?.index)).to.be(false); await browser.refresh(); @@ -140,14 +140,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await unifiedTabs.hasUnsavedIndicator(selectedTab1?.index)).to.be(true); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab2?.index)).to.be(true); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab3?.index)).to.be(false); - expect(await discover.hasUnsavedChangesBadge()).to.be(true); + expect(await discover.hasUnsavedChangesIndicator()).to.be(true); await discover.saveSearch(SEARCH_NAME); await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab1?.index)).to.be(false); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab2?.index)).to.be(false); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); }); it('forces a refetch on previously modified tab when switching back after reverting changes', async () => { @@ -174,7 +174,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilSearchingHasFinished(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab1?.index)).to.be(true); - expect(await discover.hasUnsavedChangesBadge()).to.be(true); + expect(await discover.hasUnsavedChangesIndicator()).to.be(true); const hitCountAfterChange = await discover.getHitCount(); expect(hitCountAfterChange).to.not.equal(originalHitCount); @@ -184,7 +184,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); expect(await unifiedTabs.hasUnsavedIndicator(selectedTab1?.index)).to.be(false); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await unifiedTabs.selectTab(0); await discover.waitUntilTabIsLoaded(); @@ -209,7 +209,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.saveSearch(SEARCH_NAME); await discover.waitUntilTabIsLoaded(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await kibanaServer.uiSettings.update({ defaultColumns: ['agent'] }); await common.navigateToApp('discover'); @@ -219,7 +219,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'agent']); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); }); }); }); diff --git a/src/platform/test/functional/apps/discover/tabs3/_time_range.ts b/src/platform/test/functional/apps/discover/tabs3/_time_range.ts index 4db30f60d025e..7667edf011798 100644 --- a/src/platform/test/functional/apps/discover/tabs3/_time_range.ts +++ b/src/platform/test/functional/apps/discover/tabs3/_time_range.ts @@ -85,7 +85,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.saveSearch(discoverSessionName); await discover.waitUntilTabIsLoaded(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await discover.clickNewSearchButton(); await discover.waitUntilTabIsLoaded(); @@ -101,20 +101,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await unifiedTabs.selectTab(2); await discover.waitUntilTabIsLoaded(); await checkInitialTimeConfiguration(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); }); it('should save different time ranges when the switch is on', async () => { await discover.loadSavedSearch(discoverSessionName); await discover.waitUntilTabIsLoaded(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await checkInitialTimeConfiguration(); await unifiedTabs.selectTab(2); await discover.waitUntilTabIsLoaded(); await checkInitialTimeConfiguration(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await timePicker.setAbsoluteRange(updatedTimeRange.start, updatedTimeRange.end); await discover.waitUntilTabIsLoaded(); @@ -122,37 +122,37 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); await checkUpdatedTimeConfiguration(); // changing the time range shouldn't trigger the unsaved changes badge for a discover session with a disabled time range setting - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await unifiedTabs.selectTab(0); await discover.waitUntilTabIsLoaded(); await checkInitialTimeConfiguration(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await discover.saveSearch(discoverSessionName, false, { storeTimeRange: true }); await discover.waitUntilTabIsLoaded(); await checkInitialTimeConfiguration(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await unifiedTabs.selectTab(2); await discover.waitUntilTabIsLoaded(); await checkUpdatedTimeConfiguration(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await unifiedTabs.selectTab(1); await discover.waitUntilTabIsLoaded(); await checkInitialTimeConfiguration(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await unifiedTabs.selectTab(0); await discover.waitUntilTabIsLoaded(); await checkInitialTimeConfiguration(); - expect(await discover.hasUnsavedChangesBadge()).to.be(false); + expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await timePicker.setAbsoluteRange(updatedTimeRange.start, updatedTimeRange.end); await discover.waitUntilTabIsLoaded(); // changing the time range should trigger the unsaved changes badge for a discover session with an enabled time range setting - expect(await discover.hasUnsavedChangesBadge()).to.be(true); + expect(await discover.hasUnsavedChangesIndicator()).to.be(true); }); }); } diff --git a/src/platform/test/functional/page_objects/discover_page.ts b/src/platform/test/functional/page_objects/discover_page.ts index c186b2b842e41..e73d17378b1ff 100644 --- a/src/platform/test/functional/page_objects/discover_page.ts +++ b/src/platform/test/functional/page_objects/discover_page.ts @@ -189,15 +189,15 @@ export class DiscoverPageObject extends FtrService { await this.testSubjects.click('discoverOpenButton'); } - public async hasUnsavedChangesBadge() { - return await this.testSubjects.exists('unsavedChangesBadge'); + public async hasUnsavedChangesIndicator() { + return await this.testSubjects.exists('split-button-notification-indicator'); } public async revertUnsavedChanges() { - await this.testSubjects.moveMouseTo('unsavedChangesBadge'); - await this.testSubjects.click('unsavedChangesBadge'); + await this.testSubjects.moveMouseTo('discoverSaveButton-secondary-button'); + await this.testSubjects.click('discoverSaveButton-secondary-button'); await this.retry.waitFor('popover is open', async () => { - return Boolean(await this.testSubjects.find('unsavedChangesBadgeMenuPanel')); + return Boolean(await this.testSubjects.find('discoverSaveButtonPopover')); }); await this.testSubjects.click('revertUnsavedChangesButton'); await this.header.waitUntilLoadingHasFinished(); @@ -205,12 +205,8 @@ export class DiscoverPageObject extends FtrService { } public async saveUnsavedChanges() { - await this.testSubjects.moveMouseTo('unsavedChangesBadge'); - await this.testSubjects.click('unsavedChangesBadge'); - await this.retry.waitFor('popover is open', async () => { - return Boolean(await this.testSubjects.find('unsavedChangesBadgeMenuPanel')); - }); - await this.testSubjects.click('saveUnsavedChangesButton'); + await this.testSubjects.moveMouseTo('discoverSaveButton'); + await this.testSubjects.click('discoverSaveButton'); await this.retry.waitFor('modal is open', async () => { return Boolean(await this.testSubjects.find('confirmSaveSavedObjectButton')); }); @@ -644,6 +640,9 @@ export class DiscoverPageObject extends FtrService { } public async selectTextBaseLang() { + await this.testSubjects.exists('app-menu-overflow-button'); + await this.testSubjects.click('app-menu-overflow-button'); + if (await this.testSubjects.exists('select-text-based-language-btn')) { await this.testSubjects.click('select-text-based-language-btn'); await this.header.waitUntilLoadingHasFinished(); diff --git a/x-pack/platform/test/functional/apps/discover/group1/reporting.ts b/x-pack/platform/test/functional/apps/discover/group1/reporting.ts index 62eb55790d7a7..4987c20379abc 100644 --- a/x-pack/platform/test/functional/apps/discover/group1/reporting.ts +++ b/x-pack/platform/test/functional/apps/discover/group1/reporting.ts @@ -164,7 +164,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return await exports.isExportPopoverOpen(); }); expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); - await reporting.openExportPopover(); }); it('becomes available when saved', async () => { @@ -175,7 +174,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return await exports.isExportPopoverOpen(); }); expect(await exports.isPopoverItemEnabled('CSV')).to.be(true); - await reporting.openExportPopover(); }); }); diff --git a/x-pack/platform/test/functional/page_objects/reporting_page.ts b/x-pack/platform/test/functional/page_objects/reporting_page.ts index fe60c49cf3875..2bf453296242b 100644 --- a/x-pack/platform/test/functional/page_objects/reporting_page.ts +++ b/x-pack/platform/test/functional/page_objects/reporting_page.ts @@ -132,6 +132,7 @@ export class ReportingPageObject extends FtrService { async openExportPopover() { this.log.debug('open export popover'); + await this.testSubjects.click('app-menu-overflow-button'); await this.exports.clickExportTopNavButton(); } From 8274b6b4d08a172c621c03332ed1ba2daa273c2e Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 15:07:46 +0100 Subject: [PATCH 18/76] Even more test fixes --- .../app_menu_actions/get_alerts.test.tsx | 106 +++++++++------ .../top_nav/discover_topnav.test.tsx | 121 ++++++++++++++---- .../top_nav/get_top_nav_badges.test.ts | 82 ------------ .../top_nav/use_top_nav_links.test.tsx | 50 ++++++-- .../discover/tabs_disabled/_reopen_session.ts | 10 +- .../test/functional/services/inspector.ts | 4 + .../test_suites/discover/x_pack/reporting.ts | 1 - 7 files changed, 207 insertions(+), 167 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx index 4248377f941f5..6dc07e5db0d20 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx @@ -7,11 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { findTestSubject } from '@elastic/eui/lib/test'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { ES_QUERY_ID } from '@kbn/rule-data-utils'; +import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; import { getAlertsAppMenuItem } from './get_alerts'; import { discoverServiceMock } from '../../../../../__mocks__/services'; import { dataViewWithTimefieldMock } from '../../../../../__mocks__/data_view_with_timefield'; @@ -19,11 +17,11 @@ import { dataViewWithNoTimefieldMock } from '../../../../../__mocks__/data_view_ import { getDiscoverStateMock } from '../../../../../__mocks__/discover_state.mock'; import type { AppMenuExtensionParams } from '../../../../../context_awareness'; -const mount = ( +const getAlertsMenuItem = ( dataView = dataViewMock, isEsqlMode = false, authorizedRuleTypeIds = [ES_QUERY_ID] -) => { +): AppMenuItemType => { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); stateContainer.actions.setDataView(dataView); @@ -37,70 +35,96 @@ const mount = ( }, }; - const alertsAppMenuItem = getAlertsAppMenuItem({ + return getAlertsAppMenuItem({ discoverParams: discoverParamsMock, services: discoverServiceMock, stateContainer, }); - - void alertsAppMenuItem; // TODO - - return mountWithIntl(
); }; -describe('OpenAlertsPopover', () => { +describe('getAlertsAppMenuItem', () => { describe('Authorized Rule Types', () => { - it('should render the manage alerts button if there is any authorized rule type', () => { - const component = mount(dataViewMock, false, ['anyAuthorizedRule']); - expect(findTestSubject(component, 'discoverManageAlertsButton').exists()).toBeTruthy(); + it('should include the manage alerts button if there is any authorized rule type', () => { + const alertsMenuItem = getAlertsMenuItem(dataViewMock, false, ['anyAuthorizedRule']); + const manageAlertsItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverManageAlertsButton' + ); + expect(manageAlertsItem).toBeDefined(); }); - it('should render the create search threshold rule button if it is authorized', () => { - const component = mount(); - expect(findTestSubject(component, 'discoverCreateAlertButton').exists()).toBeTruthy(); + it('should include the create search threshold rule button if it is authorized', () => { + const alertsMenuItem = getAlertsMenuItem(); + const createAlertItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverCreateAlertButton' + ); + expect(createAlertItem).toBeDefined(); }); - it('should not render the create search threshold rule button if it is not authorized', () => { - const component = mount(dataViewMock, false, []); - expect(findTestSubject(component, 'discoverCreateAlertButton').exists()).toBeFalsy(); + it('should not include the create search threshold rule button if it is not authorized', () => { + const alertsMenuItem = getAlertsMenuItem(dataViewMock, false, []); + const createAlertItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverCreateAlertButton' + ); + expect(createAlertItem).toBeUndefined(); }); }); describe('Dataview mode', () => { - it('should render with the create search threshold rule button disabled if the data view has no time field', () => { - const component = mount(); - expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeTruthy(); + it('should have the create search threshold rule button disabled if the data view has no time field', () => { + const alertsMenuItem = getAlertsMenuItem(); + const createAlertItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverCreateAlertButton' + ); + expect(createAlertItem?.disableButton).toBe(true); }); - it('should render with the create search threshold rule button enabled if the data view has a time field', () => { - const component = mount(dataViewWithTimefieldMock); - expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeFalsy(); + it('should have the create search threshold rule button enabled if the data view has a time field', () => { + const alertsMenuItem = getAlertsMenuItem(dataViewWithTimefieldMock); + const createAlertItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverCreateAlertButton' + ); + expect(createAlertItem?.disableButton).toBe(false); }); - it('should render the manage rules and connectors link', () => { - const component = mount(); - expect(findTestSubject(component, 'discoverManageAlertsButton').exists()).toBeTruthy(); + it('should include the manage rules and connectors link', () => { + const alertsMenuItem = getAlertsMenuItem(); + const manageAlertsItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverManageAlertsButton' + ); + expect(manageAlertsItem).toBeDefined(); }); }); describe('ES|QL mode', () => { - it('should render with the create search threshold rule button enabled if the data view has no timeFieldName but at least one time field', () => { - const component = mount(dataViewMock, true); - expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeFalsy(); + it('should have the create search threshold rule button enabled if the data view has no timeFieldName but at least one time field', () => { + const alertsMenuItem = getAlertsMenuItem(dataViewMock, true); + const createAlertItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverCreateAlertButton' + ); + expect(createAlertItem?.disableButton).toBe(false); }); - it('should render with the create search threshold rule button enabled if the data view has a time field', () => { - const component = mount(dataViewWithTimefieldMock, true); - expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeFalsy(); + it('should have the create search threshold rule button enabled if the data view has a time field', () => { + const alertsMenuItem = getAlertsMenuItem(dataViewWithTimefieldMock, true); + const createAlertItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverCreateAlertButton' + ); + expect(createAlertItem?.disableButton).toBe(false); }); - it('should render with the create search threshold rule button disabled if the data view has no time fields at all', () => { - const component = mount(dataViewWithNoTimefieldMock, true); - expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeTruthy(); + it('should have the create search threshold rule button disabled if the data view has no time fields at all', () => { + const alertsMenuItem = getAlertsMenuItem(dataViewWithNoTimefieldMock, true); + const createAlertItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverCreateAlertButton' + ); + expect(createAlertItem?.disableButton).toBe(true); }); - it('should render the manage rules and connectors link', () => { - const component = mount(); - expect(findTestSubject(component, 'discoverManageAlertsButton').exists()).toBeTruthy(); + it('should include the manage rules and connectors link', () => { + const alertsMenuItem = getAlertsMenuItem(); + const manageAlertsItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverManageAlertsButton' + ); + expect(manageAlertsItem).toBeDefined(); }); }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx index 41ec4023b4175..232313a1cb184 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx @@ -13,8 +13,7 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import type { DiscoverTopNavProps } from './discover_topnav'; import { DiscoverTopNav } from './discover_topnav'; -import type { TopNavMenuData } from '@kbn/navigation-plugin/public'; -import { TopNavMenu } from '@kbn/navigation-plugin/public'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { discoverServiceMock as mockDiscoverService } from '../../../../__mocks__/services'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; @@ -25,6 +24,7 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import { internalStateActions } from '../../state_management/redux'; import { DiscoverTestProvider } from '../../../../__mocks__/test_provider'; import { DiscoverTopNavMenuProvider } from './discover_topnav_menu'; +import { useDiscoverTopNav } from './use_discover_topnav'; jest.mock('@kbn/kibana-react-plugin/public', () => ({ ...jest.requireActual('@kbn/kibana-react-plugin/public'), @@ -54,12 +54,19 @@ const mockSearchBarCustomizationWithHiddenDataViewPicker: SearchBarCustomization }; let mockUseCustomizations = false; +let mockAppMenuConfig: AppMenuConfig = { + items: [], +}; jest.mock('../../../../customizations', () => ({ ...jest.requireActual('../../../../customizations'), useDiscoverCustomization: jest.fn(), })); +jest.mock('./use_discover_topnav', () => ({ + useDiscoverTopNav: jest.fn(), +})); + const mockDefaultCapabilities = { discover_v2: { save: true }, } as unknown as typeof mockDiscoverService.capabilities; @@ -124,23 +131,51 @@ describe('Discover topnav component', () => { mockUseKibana.mockReturnValue({ services: mockDiscoverService, }); + + (useDiscoverTopNav as jest.Mock).mockImplementation(() => ({ + topNavMenu: mockAppMenuConfig, + topNavBadges: [], + })); }); - test('generated config of TopNavMenu config is correct when discover save permissions are assigned', () => { + test('generated config of AppMenuConfig is correct when discover save permissions are assigned', () => { + mockAppMenuConfig = { + items: [ + { id: 'inspect', label: 'Inspect', iconType: 'inspect', order: 1, run: jest.fn() }, + { id: 'new', label: 'New', iconType: 'plusInCircle', order: 2, run: jest.fn() }, + { id: 'open', label: 'Open', iconType: 'folderOpen', order: 3, run: jest.fn() }, + ], + primaryActionItem: { + id: 'save', + label: 'Save', + iconType: 'save', + run: jest.fn(), + }, + }; const props = getProps({ capabilities: { discover_v2: { save: true } } }); - const component = getTestComponent(props); - const topNavMenu = component.find(TopNavMenu).at(0); - const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'save']); + getTestComponent(props); + + const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; + const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + expect(itemIds).toEqual(['inspect', 'new', 'open']); + expect(topNavMenu.primaryActionItem?.id).toBe('save'); }); - test('generated config of TopNavMenu config is correct when no discover save permissions are assigned', () => { + test('generated config of AppMenuConfig is correct when no discover save permissions are assigned', () => { + mockAppMenuConfig = { + items: [ + { id: 'inspect', label: 'Inspect', iconType: 'inspect', order: 1, run: jest.fn() }, + { id: 'new', label: 'New', iconType: 'plusInCircle', order: 2, run: jest.fn() }, + { id: 'open', label: 'Open', iconType: 'folderOpen', order: 3, run: jest.fn() }, + ], + }; const props = getProps({ capabilities: { discover_v2: { save: false } } }); - const component = getTestComponent(props); + getTestComponent(props); - const topNavMenu = component.find(TopNavMenu).at(0).props(); - const topMenuConfig = topNavMenu.config?.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual(['inspect', 'new', 'open']); + const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; + const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + expect(itemIds).toEqual(['inspect', 'new', 'open']); + expect(topNavMenu.primaryActionItem).toBeUndefined(); }); describe('top nav customization', () => { @@ -154,12 +189,15 @@ describe('Discover topnav component', () => { inspectItem: { disabled: true }, saveItem: { disabled: true }, }; + mockAppMenuConfig = { + items: [], + }; const props = getProps(); - const component = getTestComponent(props); + getTestComponent(props); - const topNavMenu = component.find(TopNavMenu).at(0); - const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual([]); + const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; + const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + expect(itemIds).toEqual([]); }); describe('share service available', () => { @@ -181,12 +219,27 @@ describe('Discover topnav component', () => { }); it('will include share menu item if the share service is available', () => { + mockAppMenuConfig = { + items: [ + { id: 'inspect', label: 'Inspect', iconType: 'inspect', order: 1, run: jest.fn() }, + { id: 'new', label: 'New', iconType: 'plusInCircle', order: 2, run: jest.fn() }, + { id: 'open', label: 'Open', iconType: 'folderOpen', order: 3, run: jest.fn() }, + { id: 'share', label: 'Share', iconType: 'share', order: 4, run: jest.fn() }, + ], + primaryActionItem: { + id: 'save', + label: 'Save', + iconType: 'save', + run: jest.fn(), + }, + }; const props = getProps(); - const component = getTestComponent(props); + getTestComponent(props); - const topNavMenu = component.find(TopNavMenu).at(0); - const topMenuConfig = topNavMenu.props().config?.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'share', 'save']); + const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; + const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + expect(itemIds).toEqual(['inspect', 'new', 'open', 'share']); + expect(topNavMenu.primaryActionItem?.id).toBe('save'); }); it('will include export menu item if there are export integrations available', () => { @@ -205,12 +258,28 @@ describe('Discover topnav component', () => { return []; }); + mockAppMenuConfig = { + items: [ + { id: 'inspect', label: 'Inspect', iconType: 'inspect', order: 1, run: jest.fn() }, + { id: 'new', label: 'New', iconType: 'plusInCircle', order: 2, run: jest.fn() }, + { id: 'open', label: 'Open', iconType: 'folderOpen', order: 3, run: jest.fn() }, + { id: 'export', label: 'Export', iconType: 'exportAction', order: 4, run: jest.fn() }, + { id: 'share', label: 'Share', iconType: 'share', order: 5, run: jest.fn() }, + ], + primaryActionItem: { + id: 'save', + label: 'Save', + iconType: 'save', + run: jest.fn(), + }, + }; const props = getProps(); - const component = getTestComponent(props); + getTestComponent(props); - const topNavMenu = component.find(TopNavMenu).at(0).props(); - const topMenuConfig = topNavMenu.config?.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual(['inspect', 'new', 'open', 'export', 'share', 'save']); + const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; + const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + expect(itemIds).toEqual(['inspect', 'new', 'open', 'export', 'share']); + expect(topNavMenu.primaryActionItem?.id).toBe('save'); }); }); }); @@ -236,7 +305,7 @@ describe('Discover topnav component', () => { const topNav = component .find(mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu) - .at(1); + .at(0); expect(topNav.prop('dataViewPickerComponentProps')).toBeUndefined(); const dataViewPickerOverride = mountWithIntl( topNav.prop('dataViewPickerOverride') as ReactElement @@ -256,7 +325,7 @@ describe('Discover topnav component', () => { const topNav = component .find(mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu) - .at(1); + .at(0); expect(topNav.prop('dataViewPickerComponentProps')).toBeUndefined(); }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.test.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.test.ts index 9bfceee458fca..0931d13150f4a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.test.ts @@ -11,8 +11,6 @@ import { getTopNavBadges } from './get_top_nav_badges'; import { createDiscoverServicesMock } from '../../../../__mocks__/services'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks'; const stateContainer = getDiscoverStateMock({ isTimeBased: true }); @@ -20,61 +18,6 @@ const discoverServiceMock = createDiscoverServicesMock(); discoverServiceMock.capabilities.discover_v2.save = true; describe('getTopNavBadges()', function () { - test('should not return the unsaved changes badge if no changes', () => { - const topNavBadges = getTopNavBadges({ - isMobile: false, - services: discoverServiceMock, - stateContainer, - }); - expect(topNavBadges).toMatchInlineSnapshot(`Array []`); - }); - - test('should return the unsaved changes badge when has changes', async () => { - const topNavBadges = getTopNavBadges({ - isMobile: false, - services: discoverServiceMock, - stateContainer, - }); - expect(topNavBadges).toMatchInlineSnapshot(` - Array [ - Object { - "badgeText": "Unsaved changes", - "renderCustomBadge": [Function], - }, - ] - `); - - expect(topNavBadges).toHaveLength(1); - const unsavedChangesBadge = topNavBadges[0]; - expect(unsavedChangesBadge.badgeText).toEqual('Unsaved changes'); - - render(unsavedChangesBadge.renderCustomBadge!({ badgeText: 'Unsaved changes' })); - await userEvent.click(screen.getByRole('button')); // open menu - expect(screen.queryByText('Save')).not.toBeNull(); - expect(screen.queryByText('Save as')).not.toBeNull(); - expect(screen.queryByText('Revert changes')).not.toBeNull(); - }); - - test('should not show save in unsaved changed badge for read-only user', async () => { - const discoverServiceMockReadOnly = createDiscoverServicesMock(); - discoverServiceMockReadOnly.capabilities.discover_v2.save = false; - const topNavBadges = getTopNavBadges({ - isMobile: false, - services: discoverServiceMockReadOnly, - stateContainer, - }); - - expect(topNavBadges).toHaveLength(1); - const unsavedChangesBadge = topNavBadges[0]; - expect(unsavedChangesBadge.badgeText).toEqual('Unsaved changes'); - - render(unsavedChangesBadge.renderCustomBadge!({ badgeText: 'Unsaved changes' })); - await userEvent.click(screen.getByRole('button')); // open menu - expect(screen.queryByText('Save')).toBeNull(); - expect(screen.queryByText('Save as')).toBeNull(); - expect(screen.queryByText('Revert changes')).not.toBeNull(); - }); - describe('managed saved search', () => { const stateContainerWithManagedSavedSearch = getDiscoverStateMock({ savedSearch: { ...savedSearchMock, managed: true }, @@ -90,31 +33,6 @@ describe('getTopNavBadges()', function () { expect(topNavBadges).toHaveLength(1); expect(topNavBadges[0].badgeText).toEqual('Managed'); }); - - test('should not show save in unsaved changed badge', async () => { - const topNavBadges = getTopNavBadges({ - isMobile: false, - services: discoverServiceMock, - stateContainer: stateContainerWithManagedSavedSearch, - }); - - expect(topNavBadges).toHaveLength(2); - const unsavedChangesBadge = topNavBadges[0]; - expect(unsavedChangesBadge.badgeText).toEqual('Unsaved changes'); - - render(unsavedChangesBadge.renderCustomBadge!({ badgeText: 'Unsaved changes' })); - await userEvent.click(screen.getByRole('button')); // open menu - expect(screen.queryByText('Save')).toBeNull(); - }); - }); - - test('should not return the unsaved changes badge when disabled in customization', () => { - const topNavBadges = getTopNavBadges({ - isMobile: false, - services: discoverServiceMock, - stateContainer, - }); - expect(topNavBadges).toMatchInlineSnapshot(`Array []`); }); describe('solutions view badge', () => { diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index bdb218439a870..b2db1fa93af24 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { BehaviorSubject } from 'rxjs'; import { useTopNavLinks } from './use_top_nav_links'; import type { DiscoverServices } from '../../../../build_services'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; @@ -20,9 +21,14 @@ import { DiscoverTestProvider } from '../../../../__mocks__/test_provider'; describe('useTopNavLinks', () => { const services = { ...createDiscoverServicesMock(), + application: { + ...createDiscoverServicesMock().application, + currentAppId$: new BehaviorSubject('discover'), + }, capabilities: { discover_v2: { save: true, + storeSearchSession: true, }, }, uiSettings: { @@ -79,10 +85,10 @@ describe('useTopNavLinks', () => { const appMenuConfig = setup(); expect(appMenuConfig.items).toBeDefined(); - expect(appMenuConfig.items!.length).toBeGreaterThan(0); + expect(appMenuConfig.items?.length).toBeGreaterThan(0); // Check for key items - const itemIds = appMenuConfig.items!.map((item) => item.id); + const itemIds = appMenuConfig.items?.map((item) => item.id); expect(itemIds).toContain('new'); expect(itemIds).toContain('open'); @@ -100,7 +106,7 @@ describe('useTopNavLinks', () => { expect(appMenuConfig.items).toBeDefined(); // Check for ESQL switch item - const esqlItem = appMenuConfig.items!.find((item) => item.id === 'esql'); + const esqlItem = appMenuConfig.items?.find((item) => item.id === 'esql'); expect(esqlItem).toBeDefined(); expect(esqlItem?.label).toBe('Switch to classic'); }); @@ -109,6 +115,7 @@ describe('useTopNavLinks', () => { describe('when share service included', () => { beforeAll(() => { services.share = sharePluginMock.createStartContract(); + jest.spyOn(services.share, 'availableIntegrations').mockReturnValue([]); }); afterAll(() => { @@ -121,12 +128,28 @@ describe('useTopNavLinks', () => { expect(appMenuConfig.items).toBeDefined(); // Check for share item - const shareItem = appMenuConfig.items!.find((item) => item.id === 'share'); + const shareItem = appMenuConfig.items?.find((item) => item.id === 'share'); expect(shareItem).toBeDefined(); expect(shareItem?.label).toBe('Share'); }); it('should include the export menu item', () => { + jest + .spyOn(services.share!, 'availableIntegrations') + .mockImplementation((_objectType, groupId) => { + if (groupId === 'export') { + return [ + { + id: 'export', + shareType: 'integration' as const, + groupId: 'export', + config: () => Promise.resolve({}), + }, + ]; + } + return []; + }); + const appMenuConfig = renderHook( () => useTopNavLinks({ @@ -147,13 +170,16 @@ describe('useTopNavLinks', () => { } ).result.current; - // Check for share item with export popover items - const shareItem = appMenuConfig.items!.find((item) => item.id === 'share'); - expect(shareItem).toBeDefined(); - - // Export should be a popover item under share - const exportItem = shareItem?.items?.find((item) => item.id === 'export'); + const exportItem = appMenuConfig.items?.find((item) => item.id === 'export'); expect(exportItem).toBeDefined(); + expect(exportItem?.label).toBe('Export'); + + // Export should have popover items + expect(exportItem?.items).toBeDefined(); + expect(exportItem?.items?.length).toBeGreaterThan(0); + + const shareItem = appMenuConfig.items?.find((item) => item.id === 'share'); + expect(shareItem).toBeDefined(); }); }); @@ -169,7 +195,7 @@ describe('useTopNavLinks', () => { it('should return the background search menu item', () => { const appMenuConfig = setup(); - const backgroundSearchItem = appMenuConfig.items!.find( + const backgroundSearchItem = appMenuConfig.items?.find( (item) => item.id === 'backgroundSearch' ); expect(backgroundSearchItem).toBeDefined(); @@ -180,7 +206,7 @@ describe('useTopNavLinks', () => { it('should NOT return the background search menu item', () => { const appMenuConfig = setup(); - const backgroundSearchItem = appMenuConfig.items!.find( + const backgroundSearchItem = appMenuConfig.items?.find( (item) => item.id === 'backgroundSearch' ); expect(backgroundSearchItem).toBeUndefined(); diff --git a/src/platform/test/functional/apps/discover/tabs_disabled/_reopen_session.ts b/src/platform/test/functional/apps/discover/tabs_disabled/_reopen_session.ts index 8f2a2e6c40dec..ea12e90044764 100644 --- a/src/platform/test/functional/apps/discover/tabs_disabled/_reopen_session.ts +++ b/src/platform/test/functional/apps/discover/tabs_disabled/_reopen_session.ts @@ -31,7 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await retry.try(async () => { expect(await discover.getHitCount()).to.be('14,004'); expect(await discover.getSavedSearchTitle()).to.be(firstSession); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await testSubjects.missingOrFail('split-button-notification-indicator'); }); const query = 'machine.os: "ios"'; @@ -41,7 +41,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await retry.try(async () => { expect(await discover.getHitCount()).to.be('2,784'); - await testSubjects.existOrFail('unsavedChangesBadge'); + await testSubjects.existOrFail('split-button-notification-indicator'); }); await discover.saveSearch(secondSession, true); @@ -51,7 +51,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await discover.getHitCount()).to.be('2,784'); expect(await queryBar.getQueryString()).to.be(query); expect(await discover.getSavedSearchTitle()).to.be(secondSession); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await testSubjects.missingOrFail('split-button-notification-indicator'); }); await discover.loadSavedSearch(firstSession); @@ -61,7 +61,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await discover.getHitCount()).to.be('14,004'); expect(await queryBar.getQueryString()).to.be(''); expect(await discover.getSavedSearchTitle()).to.be(firstSession); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await testSubjects.missingOrFail('split-button-notification-indicator'); }); await discover.loadSavedSearch(secondSession); @@ -71,7 +71,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await discover.getHitCount()).to.be('2,784'); expect(await queryBar.getQueryString()).to.be(query); expect(await discover.getSavedSearchTitle()).to.be(secondSession); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await testSubjects.missingOrFail('split-button-notification-indicator'); }); }); }); diff --git a/src/platform/test/functional/services/inspector.ts b/src/platform/test/functional/services/inspector.ts index 3cfecae420107..eb19621cdf1be 100644 --- a/src/platform/test/functional/services/inspector.ts +++ b/src/platform/test/functional/services/inspector.ts @@ -56,9 +56,13 @@ export class InspectorService extends FtrService { */ public async open(openButton: string = 'openInspectorButton'): Promise { this.log.debug('Inspector.open'); + const isOpen = await this.testSubjects.exists('inspectorPanel'); if (!isOpen) { await this.retry.try(async () => { + if (await this.testSubjects.exists('app-menu-overflow-button')) { + await this.testSubjects.click('app-menu-overflow-button'); + } await this.testSubjects.click(openButton); await this.testSubjects.exists('inspectorPanel'); }); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts index 29e887d6ff61f..bca322b472fe9 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts @@ -105,7 +105,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return await PageObjects.exports.isExportPopoverOpen(); }); expect(await PageObjects.exports.isPopoverItemEnabled('CSV')).to.be(true); - await PageObjects.reporting.openExportPopover(); }); it('becomes available when saved', async () => { From 92160d0c6eba44d87e5e72595656ac684353409c Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 17:00:36 +0100 Subject: [PATCH 19/76] MOREEEE test fixes --- src/platform/test/functional/apps/discover/esql/_esql_view.ts | 4 ++++ .../test/functional/page_objects/unified_search_page.ts | 1 + .../test/functional/apps/discover/group1/reporting.ts | 1 - .../discover/group2/feature_controls/discover_security.ts | 1 + .../functional/test_suites/discover/esql/_esql_view.ts | 4 ++++ .../functional/test_suites/discover/x_pack/reporting.ts | 1 - 6 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/platform/test/functional/apps/discover/esql/_esql_view.ts b/src/platform/test/functional/apps/discover/esql/_esql_view.ts index 6f71e07aa8e94..26e6dc3978817 100644 --- a/src/platform/test/functional/apps/discover/esql/_esql_view.ts +++ b/src/platform/test/functional/apps/discover/esql/_esql_view.ts @@ -299,6 +299,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show switch modal when switching to a data view', async () => { await discover.selectTextBaseLang(); await discover.waitUntilTabIsLoaded(); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); @@ -312,6 +313,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); await discover.waitUntilTabIsLoaded(); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); @@ -323,6 +325,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); await discover.saveSearch('esql_test'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('switch-to-dataviews'); await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); @@ -336,6 +339,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); await discover.waitUntilTabIsLoaded(); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); diff --git a/src/platform/test/functional/page_objects/unified_search_page.ts b/src/platform/test/functional/page_objects/unified_search_page.ts index 4c9c9e5f4976b..03bb7059f1ac3 100644 --- a/src/platform/test/functional/page_objects/unified_search_page.ts +++ b/src/platform/test/functional/page_objects/unified_search_page.ts @@ -67,6 +67,7 @@ export class UnifiedSearchPageObject extends FtrService { } public async switchToDataViewMode() { + await this.testSubjects.click('app-menu-overflow-button'); await this.testSubjects.click('switch-to-dataviews'); await this.retry.waitFor('the modal to open', async () => { return await this.testSubjects.exists('discover-esql-to-dataview-modal'); diff --git a/x-pack/platform/test/functional/apps/discover/group1/reporting.ts b/x-pack/platform/test/functional/apps/discover/group1/reporting.ts index 4987c20379abc..17e0b50ebd154 100644 --- a/x-pack/platform/test/functional/apps/discover/group1/reporting.ts +++ b/x-pack/platform/test/functional/apps/discover/group1/reporting.ts @@ -112,7 +112,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await reporting.clickGenerateReportButton(); await exports.closeExportFlyout(); - await exports.clickExportTopNavButton(); const url = await reporting.getReportURL(timeout); const res = await reporting.getResponse(url ?? ''); diff --git a/x-pack/platform/test/functional/apps/discover/group2/feature_controls/discover_security.ts b/x-pack/platform/test/functional/apps/discover/group2/feature_controls/discover_security.ts index b898cc9abd55c..6629a606e2c9a 100644 --- a/x-pack/platform/test/functional/apps/discover/group2/feature_controls/discover_security.ts +++ b/x-pack/platform/test/functional/apps/discover/group2/feature_controls/discover_security.ts @@ -144,6 +144,7 @@ export default function (ctx: FtrProviderContext) { }); it('shows CSV reports', async () => { + await testSubjects.click('app-menu-overflow-button'); await exports.clickExportTopNavButton(); await exports.clickPopoverItem('CSV'); await testSubjects.existOrFail('generateReportButton'); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts index 0c752e85d8113..516fadba7bf3a 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts @@ -277,6 +277,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.selectTextBaseLang(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); @@ -292,6 +293,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('querySubmitButton'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); @@ -303,6 +305,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); await PageObjects.discover.saveSearch('esql_test'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('switch-to-dataviews'); await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); @@ -319,6 +322,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('querySubmitButton'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts index bca322b472fe9..516fcc39a2488 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts @@ -118,7 +118,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return await PageObjects.exports.isExportPopoverOpen(); }); expect(await PageObjects.exports.isPopoverItemEnabled('CSV')).to.be(true); - await PageObjects.reporting.openExportPopover(); }); }); From 28be8f4dd5234928500456efb5e12150f2e25ceb Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 17:10:48 +0100 Subject: [PATCH 20/76] Remove single tab handling --- src/platform/plugins/shared/discover/moon.yml | 1 - .../main/components/top_nav/discover_topnav_menu.tsx | 11 +---------- src/platform/plugins/shared/discover/tsconfig.json | 1 - 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index d363ef6f17759..228bd757c0513 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -130,7 +130,6 @@ dependsOn: - '@kbn/cps' - '@kbn/controls-renderer' - '@kbn/core-chrome-app-menu-components' - - '@kbn/core-chrome-app-menu' - '@kbn/shared-ux-error-boundary' tags: - plugin diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx index a521d423e1845..f89a37d8108a7 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx @@ -17,7 +17,6 @@ import React, { import { BehaviorSubject } from 'rxjs'; import useUnmount from 'react-use/lib/useUnmount'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; -import { AppMenu } from '@kbn/core-chrome-app-menu'; import type { useDiscoverTopNav } from './use_discover_topnav'; /** @@ -53,20 +52,12 @@ export const DiscoverTopNavMenuProvider = ({ children }: PropsWithChildren) => { export const DiscoverTopNavMenu = ({ topNavMenu, - renderAppMenuOutsideTabs, - setAppMenu, -}: Pick, 'topNavMenu'> & { - renderAppMenuOutsideTabs: boolean; - setAppMenu: (config?: AppMenuConfig) => void; -}) => { +}: Pick, 'topNavMenu'>) => { const { topNavMenu$ } = useContext(discoverTopNavMenuContext); useLayoutEffect(() => { topNavMenu$.next(topNavMenu); }, [topNavMenu, topNavMenu$]); - if (renderAppMenuOutsideTabs) { - return ; - } return null; }; diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 1c0e3fe8eb3f0..99b61bf6c33c0 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -123,7 +123,6 @@ "@kbn/cps", "@kbn/controls-renderer", "@kbn/core-chrome-app-menu-components", - "@kbn/core-chrome-app-menu", "@kbn/shared-ux-error-boundary", ], "exclude": ["target/**/*"] From 8b5ed2e82fa77ec17cc841cdfe425293f668472e Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 21:11:07 +0100 Subject: [PATCH 21/76] Test changes --- .../playwright/page_objects/discover_app.ts | 21 +++++- .../apps/discover/esql/_esql_view.ts | 12 ++-- .../discover/group6/_unsaved_changes_badge.ts | 66 +++++++++---------- .../functional/page_objects/discover_page.ts | 55 +++++++++++++++- .../functional/page_objects/export_page.ts | 11 +++- .../page_objects/unified_search_page.ts | 4 +- .../apps/discover/group1/reporting.ts | 1 - .../feature_controls/discover_security.ts | 1 - .../functional/page_objects/reporting_page.ts | 14 +++- .../test_suites/discover/esql/_esql_view.ts | 12 ++-- ...unsaved_changes_notification_indicator.ts} | 59 ++++++++--------- .../test_suites/discover/group6/index.ts | 2 +- .../test_suites/discover/x_pack/reporting.ts | 2 - 13 files changed, 167 insertions(+), 93 deletions(-) rename x-pack/platform/test/serverless/functional/test_suites/discover/group6/{_unsaved_changes_badge.ts => _unsaved_changes_notification_indicator.ts} (76%) diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/discover_app.ts b/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/discover_app.ts index 76dccecef2384..486c8425bd44c 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/discover_app.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/discover_app.ts @@ -252,9 +252,28 @@ export class DiscoverApp { } async selectTextBaseLang() { - if (await this.page.testSubj.isEnabled('select-text-based-language-btn')) { + // First check if the button is directly visible + if (await this.page.testSubj.isVisible('select-text-based-language-btn')) { + await this.page.testSubj.isEnabled('select-text-based-language-btn'); await this.page.testSubj.click('select-text-based-language-btn'); await this.waitForDocTableRendered(); + return; + } + + // If not visible, try the overflow menu + if (await this.page.testSubj.isVisible('app-menu-overflow-button')) { + await this.page.testSubj.click('app-menu-overflow-button'); + + if (await this.page.testSubj.isVisible('select-text-based-language-btn')) { + await this.page.testSubj.isEnabled('select-text-based-language-btn'); + await this.page.testSubj.click('select-text-based-language-btn'); + await this.waitForDocTableRendered(); + } + + // Close the popover if open + if (await this.page.testSubj.isVisible('app-menu-popover')) { + await this.page.testSubj.click('app-menu-overflow-button'); + } } } diff --git a/src/platform/test/functional/apps/discover/esql/_esql_view.ts b/src/platform/test/functional/apps/discover/esql/_esql_view.ts index 26e6dc3978817..6da15cfae0d2e 100644 --- a/src/platform/test/functional/apps/discover/esql/_esql_view.ts +++ b/src/platform/test/functional/apps/discover/esql/_esql_view.ts @@ -299,8 +299,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show switch modal when switching to a data view', async () => { await discover.selectTextBaseLang(); await discover.waitUntilTabIsLoaded(); - await testSubjects.click('app-menu-overflow-button'); - await testSubjects.click('switch-to-dataviews'); + await discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -313,8 +312,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); await discover.waitUntilTabIsLoaded(); - await testSubjects.click('app-menu-overflow-button'); - await testSubjects.click('switch-to-dataviews'); + await discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -325,8 +323,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); await discover.saveSearch('esql_test'); - await testSubjects.click('app-menu-overflow-button'); - await testSubjects.click('switch-to-dataviews'); + await discover.selectDataViewMode(); await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); @@ -339,8 +336,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); await discover.waitUntilTabIsLoaded(); - await testSubjects.click('app-menu-overflow-button'); - await testSubjects.click('switch-to-dataviews'); + await discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); diff --git a/src/platform/test/functional/apps/discover/group6/_unsaved_changes_badge.ts b/src/platform/test/functional/apps/discover/group6/_unsaved_changes_badge.ts index 8cae0684ec23b..338a550e48baf 100644 --- a/src/platform/test/functional/apps/discover/group6/_unsaved_changes_badge.ts +++ b/src/platform/test/functional/apps/discover/group6/_unsaved_changes_badge.ts @@ -35,7 +35,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { hideAnnouncements: true, }; - describe('discover unsaved changes badge', function describeIndexTests() { + describe('discover unsaved changes notification indicator', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); await esArchiver.loadIfNeeded( @@ -62,51 +62,51 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilSearchingHasFinished(); }); - it('should not show the badge initially nor after changes to a draft saved search', async () => { - await testSubjects.missingOrFail('unsavedChangesBadge'); + it('should not show the notification indicator initially nor after changes to a draft saved search', async () => { + await discover.ensureNoUnsavedChangesIndicator(); await unifiedFieldList.clickFieldListItemAdd('bytes'); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); }); - it('should show the badge only after changes to a persisted saved search', async () => { + it('should show the notification indicator only after changes to a persisted saved search', async () => { await discover.saveSearch(SAVED_SEARCH_NAME); await discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); await unifiedFieldList.clickFieldListItemAdd('bytes'); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await discover.ensureHasUnsavedChangesIndicator(); await discover.saveUnsavedChanges(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); }); - it('should not show a badge after loading a saved search, only after changes', async () => { + it('should not show a notification indicator after loading a saved search, only after changes', async () => { await discover.loadSavedSearch(SAVED_SEARCH_NAME); await discover.waitUntilTabIsLoaded(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); await discover.chooseBreakdownField('_index'); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await discover.ensureHasUnsavedChangesIndicator(); }); it('should allow to revert changes', async () => { await discover.loadSavedSearch(SAVED_SEARCH_NAME); await discover.waitUntilTabIsLoaded(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); // test changes to columns expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); @@ -114,10 +114,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes', 'extension']); - await testSubjects.existOrFail('unsavedChangesBadge'); + await discover.ensureHasUnsavedChangesIndicator(); await discover.revertUnsavedChanges(); expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); // test changes to sample size await dataGrid.clickGridSettings(); @@ -126,12 +126,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.clickGridSettings(); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await discover.ensureHasUnsavedChangesIndicator(); await dataGrid.clickGridSettings(); expect(await dataGrid.getCurrentSampleSizeValue()).to.be(250); await dataGrid.clickGridSettings(); await discover.revertUnsavedChanges(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); await dataGrid.clickGridSettings(); expect(await dataGrid.getCurrentSampleSizeValue()).to.be(500); await dataGrid.clickGridSettings(); @@ -141,16 +141,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.changeRowsPerPageTo(25); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await discover.ensureHasUnsavedChangesIndicator(); await dataGrid.checkCurrentRowsPerPageToBe(25); await discover.revertUnsavedChanges(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); await dataGrid.checkCurrentRowsPerPageToBe(100); }); - it('should hide the badge once user manually reverts changes', async () => { + it('should hide the notification indicator once user manually reverts changes', async () => { await discover.loadSavedSearch(SAVED_SEARCH_NAME); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); // changes to columns expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); @@ -158,46 +158,46 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes', 'extension']); - await testSubjects.existOrFail('unsavedChangesBadge'); + await discover.ensureHasUnsavedChangesIndicator(); await unifiedFieldList.clickFieldListItemRemove('extension'); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); // test changes to breakdown field await discover.chooseBreakdownField('_index'); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await discover.ensureHasUnsavedChangesIndicator(); await discover.clearBreakdownField(); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); }); - it('should not show the badge after pinning the first filter but after disabling a filter', async () => { + it('should not show the notification indicator after pinning the first filter but after disabling a filter', async () => { await filterBar.addFilter({ field: 'extension', operation: 'is', value: 'png' }); await filterBar.addFilter({ field: 'bytes', operation: 'exists' }); await discover.saveSearch(SAVED_SEARCH_WITH_FILTERS_NAME); await discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); await filterBar.toggleFilterPinned('extension'); await discover.waitUntilSearchingHasFinished(); expect(await filterBar.isFilterPinned('extension')).to.be(true); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); await filterBar.toggleFilterNegated('bytes'); await discover.waitUntilSearchingHasFinished(); expect(await filterBar.isFilterNegated('bytes')).to.be(true); - await testSubjects.existOrFail('unsavedChangesBadge'); + await discover.ensureHasUnsavedChangesIndicator(); await discover.revertUnsavedChanges(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); expect(await filterBar.getFilterCount()).to.be(2); expect(await filterBar.isFilterPinned('extension')).to.be(false); @@ -205,7 +205,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await discover.getHitCount()).to.be('1,373'); }); - it('should not show a badge after loading an ES|QL saved search, only after changes', async () => { + it('should not show a notification indicator after loading an ES|QL saved search, only after changes', async () => { await discover.selectTextBaseLang(); await monacoEditor.setCodeEditorValue('from logstash-* | limit 10'); @@ -216,20 +216,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.saveSearch(SAVED_SEARCH_ESQL); await discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); await browser.refresh(); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await discover.ensureNoUnsavedChangesIndicator(); await monacoEditor.setCodeEditorValue('from logstash-* | limit 100'); await testSubjects.click('querySubmitButton'); await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await discover.ensureHasUnsavedChangesIndicator(); }); }); } diff --git a/src/platform/test/functional/page_objects/discover_page.ts b/src/platform/test/functional/page_objects/discover_page.ts index e73d17378b1ff..f69edfa10add8 100644 --- a/src/platform/test/functional/page_objects/discover_page.ts +++ b/src/platform/test/functional/page_objects/discover_page.ts @@ -640,13 +640,54 @@ export class DiscoverPageObject extends FtrService { } public async selectTextBaseLang() { - await this.testSubjects.exists('app-menu-overflow-button'); - await this.testSubjects.click('app-menu-overflow-button'); - + // First check if the button is directly visible if (await this.testSubjects.exists('select-text-based-language-btn')) { await this.testSubjects.click('select-text-based-language-btn'); await this.header.waitUntilLoadingHasFinished(); await this.waitUntilSearchingHasFinished(); + return; + } + + // If not visible, try the overflow menu + if (await this.testSubjects.exists('app-menu-overflow-button')) { + await this.testSubjects.click('app-menu-overflow-button'); + + if (await this.testSubjects.exists('select-text-based-language-btn')) { + await this.testSubjects.click('select-text-based-language-btn'); + await this.header.waitUntilLoadingHasFinished(); + await this.waitUntilSearchingHasFinished(); + } + + // Close the popover if open + if (await this.testSubjects.exists('app-menu-popover')) { + await this.testSubjects.click('app-menu-overflow-button'); + } + } + } + + public async selectDataViewMode() { + // First check if the button is directly visible + if (await this.testSubjects.exists('switch-to-dataviews')) { + await this.testSubjects.click('switch-to-dataviews'); + await this.header.waitUntilLoadingHasFinished(); + await this.waitUntilSearchingHasFinished(); + return; + } + + // If not visible, try the overflow menu + if (await this.testSubjects.exists('app-menu-overflow-button')) { + await this.testSubjects.click('app-menu-overflow-button'); + + if (await this.testSubjects.exists('switch-to-dataviews')) { + await this.testSubjects.click('switch-to-dataviews'); + await this.header.waitUntilLoadingHasFinished(); + await this.waitUntilSearchingHasFinished(); + } + + // Close the popover if open + if (await this.testSubjects.exists('app-menu-popover')) { + await this.testSubjects.click('app-menu-overflow-button'); + } } } @@ -937,4 +978,12 @@ export class DiscoverPageObject extends FtrService { } await this.expectRequestCount(endpointRegExp, expectedCount); } + + public async ensureHasUnsavedChangesIndicator() { + await this.testSubjects.existOrFail('split-button-notification-indicator'); + } + + public async ensureNoUnsavedChangesIndicator() { + await this.testSubjects.missingOrFail('split-button-notification-indicator'); + } } diff --git a/src/platform/test/functional/page_objects/export_page.ts b/src/platform/test/functional/page_objects/export_page.ts index 6d68ce1f60bc4..d8c615e13d19f 100644 --- a/src/platform/test/functional/page_objects/export_page.ts +++ b/src/platform/test/functional/page_objects/export_page.ts @@ -24,7 +24,16 @@ export class ExportPageObject extends FtrService { } async clickExportTopNavButton() { - return this.testSubjects.click('exportTopNavButton'); + // First check if export button is directly visible + if (await this.testSubjects.exists('exportTopNavButton')) { + return await this.testSubjects.click('exportTopNavButton'); + } + + // If not visible, try the overflow menu + if (await this.testSubjects.exists('app-menu-overflow-button')) { + await this.testSubjects.click('app-menu-overflow-button'); + return await this.testSubjects.click('exportTopNavButton'); + } } async isExportPopoverOpen() { diff --git a/src/platform/test/functional/page_objects/unified_search_page.ts b/src/platform/test/functional/page_objects/unified_search_page.ts index 03bb7059f1ac3..ce7423174915f 100644 --- a/src/platform/test/functional/page_objects/unified_search_page.ts +++ b/src/platform/test/functional/page_objects/unified_search_page.ts @@ -13,6 +13,7 @@ export class UnifiedSearchPageObject extends FtrService { private readonly retry = this.ctx.getService('retry'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly find = this.ctx.getService('find'); + private readonly PageObjects = this.ctx.getPageObjects(['discover']); public async switchDataView(switchButtonSelector: string, dataViewTitle: string) { await this.testSubjects.click(switchButtonSelector); @@ -67,8 +68,7 @@ export class UnifiedSearchPageObject extends FtrService { } public async switchToDataViewMode() { - await this.testSubjects.click('app-menu-overflow-button'); - await this.testSubjects.click('switch-to-dataviews'); + await this.PageObjects.discover.selectDataViewMode(); await this.retry.waitFor('the modal to open', async () => { return await this.testSubjects.exists('discover-esql-to-dataview-modal'); }); diff --git a/x-pack/platform/test/functional/apps/discover/group1/reporting.ts b/x-pack/platform/test/functional/apps/discover/group1/reporting.ts index 17e0b50ebd154..457935a74795c 100644 --- a/x-pack/platform/test/functional/apps/discover/group1/reporting.ts +++ b/x-pack/platform/test/functional/apps/discover/group1/reporting.ts @@ -101,7 +101,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const getReport = async ({ timeout } = { timeout: 60 * 1000 }) => { // close any open notification toasts await toasts.dismissAll(); - await exports.clickExportTopNavButton(); await retry.waitFor('the popover to be opened', async () => { return await exports.isExportPopoverOpen(); diff --git a/x-pack/platform/test/functional/apps/discover/group2/feature_controls/discover_security.ts b/x-pack/platform/test/functional/apps/discover/group2/feature_controls/discover_security.ts index 6629a606e2c9a..b898cc9abd55c 100644 --- a/x-pack/platform/test/functional/apps/discover/group2/feature_controls/discover_security.ts +++ b/x-pack/platform/test/functional/apps/discover/group2/feature_controls/discover_security.ts @@ -144,7 +144,6 @@ export default function (ctx: FtrProviderContext) { }); it('shows CSV reports', async () => { - await testSubjects.click('app-menu-overflow-button'); await exports.clickExportTopNavButton(); await exports.clickPopoverItem('CSV'); await testSubjects.existOrFail('generateReportButton'); diff --git a/x-pack/platform/test/functional/page_objects/reporting_page.ts b/x-pack/platform/test/functional/page_objects/reporting_page.ts index 2bf453296242b..7e14c2ffc5d8a 100644 --- a/x-pack/platform/test/functional/page_objects/reporting_page.ts +++ b/x-pack/platform/test/functional/page_objects/reporting_page.ts @@ -132,8 +132,18 @@ export class ReportingPageObject extends FtrService { async openExportPopover() { this.log.debug('open export popover'); - await this.testSubjects.click('app-menu-overflow-button'); - await this.exports.clickExportTopNavButton(); + + // First check if export button is directly visible + if (await this.testSubjects.exists('exportTopNavButton')) { + await this.exports.clickExportTopNavButton(); + return; + } + + // If not visible, try the overflow menu + if (await this.testSubjects.exists('app-menu-overflow-button')) { + await this.testSubjects.click('app-menu-overflow-button'); + await this.exports.clickExportTopNavButton(); + } } async selectExportItem(label: string) { diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts index 516fadba7bf3a..74f36bb1ba78d 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts @@ -277,8 +277,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.selectTextBaseLang(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('app-menu-overflow-button'); - await testSubjects.click('switch-to-dataviews'); + await PageObjects.discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -293,8 +292,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('querySubmitButton'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('app-menu-overflow-button'); - await testSubjects.click('switch-to-dataviews'); + await PageObjects.discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -305,8 +303,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); await PageObjects.discover.saveSearch('esql_test'); - await testSubjects.click('app-menu-overflow-button'); - await testSubjects.click('switch-to-dataviews'); + await PageObjects.discover.selectDataViewMode(); await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); @@ -322,8 +319,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('querySubmitButton'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('app-menu-overflow-button'); - await testSubjects.click('switch-to-dataviews'); + await PageObjects.discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_badge.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts similarity index 76% rename from x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_badge.ts rename to x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts index de03f93d44393..eb1f8310aa99a 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_badge.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts @@ -14,7 +14,6 @@ const SAVED_SEARCH_WITH_FILTERS_NAME = 'test saved search with filters'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); - const testSubjects = getService('testSubjects'); const dataGrid = getService('dataGrid'); const filterBar = getService('filterBar'); const PageObjects = getPageObjects([ @@ -32,7 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { hideAnnouncements: true, }; - describe('discover unsaved changes badge', function describeIndexTests() { + describe('discover unsaved changes notification indicator', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); await esArchiver.loadIfNeeded( @@ -60,18 +59,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.waitUntilSearchingHasFinished(); }); - it('should not show the badge initially nor after changes to a draft saved search', async () => { - await testSubjects.missingOrFail('unsavedChangesBadge'); + it('should not show the notification indicator initially nor after changes to a draft saved search', async () => { + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); await PageObjects.unifiedFieldList.clickFieldListItemAdd('bytes'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); }); - it('should show the badge only after changes to a persisted saved search', async () => { + it('should show the notification indicator only after changes to a persisted saved search', async () => { await dataViews.createFromSearchBar({ name: 'lo', // Must be anything but log/logs, since pagination is disabled for log sources adHoc: true, @@ -80,36 +79,36 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.saveSearch(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); await PageObjects.unifiedFieldList.clickFieldListItemAdd('bytes'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureHasUnsavedChangesIndicator(); await PageObjects.discover.saveUnsavedChanges(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); }); - it('should not show a badge after loading a saved search, only after changes', async () => { + it('should not show a notification indicator after loading a saved search, only after changes', async () => { await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); await PageObjects.discover.chooseBreakdownField('_index'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureHasUnsavedChangesIndicator(); }); it('should allow to revert changes', async () => { await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); // test changes to columns expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); @@ -117,10 +116,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes', 'extension']); - await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureHasUnsavedChangesIndicator(); await PageObjects.discover.revertUnsavedChanges(); expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); // test changes to sample size await dataGrid.clickGridSettings(); @@ -129,12 +128,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.clickGridSettings(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureHasUnsavedChangesIndicator(); await dataGrid.clickGridSettings(); expect(await dataGrid.getCurrentSampleSizeValue()).to.be(250); await dataGrid.clickGridSettings(); await PageObjects.discover.revertUnsavedChanges(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); await dataGrid.clickGridSettings(); expect(await dataGrid.getCurrentSampleSizeValue()).to.be(500); await dataGrid.clickGridSettings(); @@ -144,17 +143,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.changeRowsPerPageTo(25); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureHasUnsavedChangesIndicator(); await dataGrid.checkCurrentRowsPerPageToBe(25); await PageObjects.discover.revertUnsavedChanges(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); await dataGrid.checkCurrentRowsPerPageToBe(100); }); - it('should hide the badge once user manually reverts changes', async () => { + it('should hide the notification indicator once user manually reverts changes', async () => { await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); // changes to columns expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); @@ -162,46 +161,46 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes', 'extension']); - await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureHasUnsavedChangesIndicator(); await PageObjects.unifiedFieldList.clickFieldListItemRemove('extension'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); expect(await dataGrid.getHeaderFields()).to.eql(['@timestamp', 'bytes']); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); // test changes to breakdown field await PageObjects.discover.chooseBreakdownField('_index'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureHasUnsavedChangesIndicator(); await PageObjects.discover.clearBreakdownField(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); }); - it('should not show the badge after pinning the first filter but after disabling a filter', async () => { + it('should not show the notification indicator after pinning the first filter but after disabling a filter', async () => { await filterBar.addFilter({ field: 'extension', operation: 'is', value: 'png' }); await filterBar.addFilter({ field: 'bytes', operation: 'exists' }); await PageObjects.discover.saveSearch(SAVED_SEARCH_WITH_FILTERS_NAME); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); await filterBar.toggleFilterPinned('extension'); await PageObjects.discover.waitUntilSearchingHasFinished(); expect(await filterBar.isFilterPinned('extension')).to.be(true); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); await filterBar.toggleFilterNegated('bytes'); await PageObjects.discover.waitUntilSearchingHasFinished(); expect(await filterBar.isFilterNegated('bytes')).to.be(true); - await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureHasUnsavedChangesIndicator(); await PageObjects.discover.revertUnsavedChanges(); - await testSubjects.missingOrFail('unsavedChangesBadge'); + await PageObjects.discover.ensureNoUnsavedChangesIndicator(); expect(await filterBar.getFilterCount()).to.be(2); expect(await filterBar.isFilterPinned('extension')).to.be(false); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/index.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/index.ts index 16dae966dcd34..f0c8f90ba0266 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/index.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/index.ts @@ -25,6 +25,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { }); loadTestFile(require.resolve('./_sidebar')); - loadTestFile(require.resolve('./_unsaved_changes_badge')); + loadTestFile(require.resolve('./_unsaved_changes_notification_indicator')); }); } diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts index 516fcc39a2488..d4ac525751c7e 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/x_pack/reporting.ts @@ -33,7 +33,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const getReport = async ({ timeout } = { timeout: 60 * 1000 }) => { // close any open notification toasts await toasts.dismissAll(); - await PageObjects.exports.clickExportTopNavButton(); await retry.waitFor('the popover to be opened', async () => { return await PageObjects.exports.isExportPopoverOpen(); @@ -44,7 +43,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await PageObjects.reporting.clickGenerateReportButton(); await PageObjects.exports.closeExportFlyout(); - await PageObjects.exports.clickExportTopNavButton(); const url = await PageObjects.reporting.getReportURL(timeout); // TODO: Fetch CSV client side in Serverless since `PageObjects.reporting.getResponse()` From a779e92040fab0468c5f0a0440f790e4219bd027 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 23:07:04 +0100 Subject: [PATCH 22/76] Revert "Remove context_awarness examples" This reverts commit 79651e6caca6c6d571f4b024187956e52b3d1734. --- .../example/example_context.ts | 22 ++ .../components/chart_with_custom_buttons.tsx | 262 +++++++++++++ .../components/index.ts | 10 + .../example_data_source_profile/index.ts | 10 + .../example_data_source_profile/profile.tsx | 368 ++++++++++++++++++ .../example/example_document_profile/index.ts | 10 + .../example_document_profile/profile.ts | 30 ++ .../example/example_root_profile/index.ts | 13 + .../example/example_root_profile/profile.tsx | 167 ++++++++ ...register_enabled_profile_providers.test.ts | 46 +++ .../register_profile_providers.test.ts | 50 ++- .../register_profile_providers.ts | 10 + 12 files changed, 997 insertions(+), 1 deletion(-) create mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_context.ts create mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/chart_with_custom_buttons.tsx create mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/index.ts create mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/index.ts create mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx create mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/index.ts create mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/profile.ts create mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts create mode 100644 src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_context.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_context.ts new file mode 100644 index 0000000000000..e9475d61f1425 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_context.ts @@ -0,0 +1,22 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createContext, useContext } from 'react'; + +const exampleContext = createContext<{ + currentMessage: string | undefined; + setCurrentMessage: (message: string | undefined) => void; +}>({ + currentMessage: undefined, + setCurrentMessage: () => {}, +}); + +export const ExampleContextProvider = exampleContext.Provider; + +export const useExampleContext = () => useContext(exampleContext); diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/chart_with_custom_buttons.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/chart_with_custom_buttons.tsx new file mode 100644 index 0000000000000..8abca0666f483 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/chart_with_custom_buttons.tsx @@ -0,0 +1,262 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ChartSectionProps } from '@kbn/unified-histogram/types'; +import type { UnifiedHistogramFetch$Arguments } from '@kbn/unified-histogram/types'; +import { UnifiedBreakdownFieldSelector } from '@kbn/unified-histogram'; +import type { LensEmbeddableInput } from '@kbn/lens-plugin/public'; +import type { DataViewField } from '@kbn/data-views-plugin/common'; +import { css } from '@emotion/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + euiPaletteColorBlind, + useEuiTheme, +} from '@elastic/eui'; +import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import type { ChartSectionConfigurationExtensionParams } from '../../../../types'; +import { + useCurrentTabAction, + useInternalStateDispatch, +} from '../../../../../application/main/state_management/redux'; +import { internalStateActions } from '../../../../../application/main/state_management/redux'; + +interface ChartWithCustomButtonsProps extends ChartSectionProps { + actions: ChartSectionConfigurationExtensionParams['actions']; +} + +export const ChartWithCustomButtons = ({ actions, ...props }: ChartWithCustomButtonsProps) => { + const { isComponentVisible, fetch$, fetchParams, onBrushEnd, renderToggleActions, services } = + props; + const { euiTheme } = useEuiTheme(); + const euiPalette = euiPaletteColorBlind(); + const { openInNewTab, updateESQLQuery } = actions; + + const dispatch = useInternalStateDispatch(); + const updateAppState = useCurrentTabAction(internalStateActions.updateAppState); + + const handleBreakdownFieldChange = useCallback( + (breakdownField: DataViewField | undefined) => { + dispatch(updateAppState({ appState: { breakdownField: breakdownField?.name } })); + }, + [dispatch, updateAppState] + ); + + const lensAttributes = useMemo(() => { + const { dataView, query, timeInterval } = fetchParams; + + if (!dataView.isTimeBased() || !dataView.timeFieldName) return null; + + const LAYER_ID = 'exampleHistogramLayer'; + const columns = { + date_column: { + dataType: 'date', + isBucketed: true, + label: dataView.timeFieldName, + operationType: 'date_histogram', + params: { interval: timeInterval || 'auto' }, + scale: 'interval', + sourceField: dataView.timeFieldName, + }, + count_column: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + params: { format: { id: 'number', params: { decimals: 0 } } }, + scale: 'ratio', + sourceField: '___records___', + }, + }; + + return { + references: [ + { + id: dataView.id || '', + name: `indexpattern-datasource-layer-${LAYER_ID}`, + type: 'index-pattern', + }, + ], + state: { + adHocDataViews: {}, + datasourceStates: { + formBased: { + layers: { + [LAYER_ID]: { + columnOrder: ['date_column', 'count_column'], + columns, + indexPatternId: dataView.id, + }, + }, + }, + }, + filters: [], + internalReferences: [], + query: query || { language: 'kuery', query: '' }, + visualization: { + layers: [ + { + accessors: ['count_column'], + layerId: LAYER_ID, + layerType: 'data', + seriesType: 'bar_stacked', + xAccessor: 'date_column', + yConfig: [{ forAccessor: 'count_column', color: euiPalette[4] }], + }, + ], + legend: { isVisible: true, position: 'right' }, + preferredSeriesType: 'bar_stacked', + showCurrentTimeMarker: true, + valueLabels: 'hide', + }, + }, + title: 'Histogram', + visualizationType: 'lnsXY', + } as Parameters[0]['attributes']; + }, [euiPalette, fetchParams, services]); + + const [externalAttributes, setExternalAttributes] = useState< + Parameters[0]['attributes'] | null + >(null); + + useEffect(() => { + const subscription = fetch$.subscribe( + ({ lensVisServiceState }: UnifiedHistogramFetch$Arguments) => { + if (lensVisServiceState?.visContext?.attributes) { + setExternalAttributes(lensVisServiceState.visContext.attributes); + } + } + ); + return () => subscription.unsubscribe(); + }, [fetch$]); + + const handleBrushEnd: NonNullable = useCallback( + (data) => { + data.preventDefault(); + + if (onBrushEnd) { + onBrushEnd(data); + } else if (data.range.length >= 2) { + const [min, max] = data.range; + const from = new Date(min).toISOString(); + const to = new Date(max).toISOString(); + services.data.query.timefilter.timefilter.setTime({ + from, + to, + mode: 'absolute', + }); + } + }, + [onBrushEnd, services.data.query.timefilter.timefilter] + ); + + const onLoad = useCallback(() => {}, []); + + if (!isComponentVisible) return null; + + const finalAttributes = externalAttributes || lensAttributes; + + const chartCss = css` + flex-grow: 1; + margin-block: ${euiTheme.size.xs}; + min-height: 200px; + position: relative; + & > div { + height: 100%; + position: absolute; + width: 100%; + } + `; + + return ( + + + + {renderToggleActions()} + {fetchParams.breakdown && ( + + + + )} + {updateESQLQuery && ( + + updateESQLQuery('FROM my-example-logs | LIMIT 50')} + size="s" + > + Update ES|QL query + + + )} + {openInNewTab && ( + + + openInNewTab({ + query: { esql: 'FROM my-example-logs | LIMIT 100' }, + tabLabel: 'Example Logs Tab', + timeRange: { + from: 'now-1d', + to: 'now', + }, + }) + } + size="s" + > + Open new tab + + + )} + + + + {finalAttributes ? ( +
+ +
+ ) : ( + + Chart not available + + )} +
+
+ ); +}; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/index.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/index.ts new file mode 100644 index 0000000000000..2467367755edb --- /dev/null +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/components/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export * from './chart_with_custom_buttons'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/index.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/index.ts new file mode 100644 index 0000000000000..03d5412fb6692 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { createExampleDataSourceProfileProvider } from './profile'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx new file mode 100644 index 0000000000000..9ff67e5044462 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx @@ -0,0 +1,368 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + EuiBadge, + EuiLink, + EuiFlyout, + EuiFlexGroup, + EuiSpacer, + EuiCodeBlock, + EuiTitle, + EuiButton, + EuiFlexItem, +} from '@elastic/eui'; +import type { RowControlColumn } from '@kbn/discover-utils'; +import { AppMenuActionId, AppMenuActionType, getFieldValue } from '@kbn/discover-utils'; +import type { DataViewField } from '@kbn/data-views-plugin/common'; +import { capitalize } from 'lodash'; +import React from 'react'; +import type { DataSourceProfileProvider } from '../../../profiles'; +import { ChartWithCustomButtons } from './components'; +import { DataSourceCategory } from '../../../profiles'; +import { useExampleContext } from '../example_context'; +import { extractIndexPatternFrom } from '../../extract_index_pattern_from'; + +export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvider<{ + formatRecord: (flattenedRecord: Record) => string; +}> => ({ + profileId: 'example-data-source-profile', + isExperimental: true, + profile: { + getCellRenderers: (prev) => (params) => ({ + ...prev(params), + 'log.level': (props) => { + const level = getFieldValue(props.row, 'log.level') as string; + + if (!level) { + return ( + ({ + color: euiTheme.colors.textSubdued, + })} + data-test-subj="exampleDataSourceProfileLogLevelEmpty" + > + (None) + + ); + } + + const levelMap: Record = { + info: 'primary', + debug: 'default', + error: 'danger', + }; + + return ( + + {capitalize(level)} + + ); + }, + message: function Message(props) { + const { currentMessage, setCurrentMessage } = useExampleContext(); + const message = getFieldValue(props.row, 'message') as string; + + return ( + setCurrentMessage(message)} + css={{ fontWeight: currentMessage === message ? 'bold' : undefined }} + data-test-subj="exampleDataSourceProfileMessage" + > + {message} + + ); + }, + }), + getDocViewer: + (prev, { context }) => + (params) => { + const { openInNewTab, updateESQLQuery } = params.actions; + const recordId = params.record.id; + const prevValue = prev(params); + + return { + title: `Record #${recordId}`, + docViewsRegistry: (registry) => { + registry.add({ + id: 'doc_view_example', + title: 'Example', + order: 0, + component: () => ( + <> + + + + +

Example doc view

+
+ {(openInNewTab || updateESQLQuery) && ( + + + {updateESQLQuery && ( + { + updateESQLQuery('FROM my-example-logs | LIMIT 5'); + }} + data-test-subj="exampleDataSourceProfileDocViewUpdateEsqlQuery" + > + Update ES|QL query + + )} + {openInNewTab && ( + { + openInNewTab({ + tabLabel: 'My new tab', + query: { esql: 'FROM my-example-logs | LIMIT 5' }, + }); + }} + data-test-subj="exampleDataSourceProfileDocViewOpenNewTab" + > + Open new tab + + )} + + + )} +
+ + {context.formatRecord(params.record.flattened)} + +
+ + ), + }); + + return prevValue.docViewsRegistry(registry); + }, + }; + }, + /** + * The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu. + * The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation. + * And it supports opening custom flyouts and any other modals on the click. + * `getAppMenu` can be configured in both root and data source profiles. + * @param prev + */ + getAppMenu: (prev) => (params) => { + const prevValue = prev(params); + + // This is what is available via params: + // const { dataView, services, isEsqlMode, adHocDataViews, actions } = params; + + return { + appMenuRegistry: (registry) => { + // Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored. + + // Can be a on-click action, link or a submenu with an array of actions and horizontal rules + registry.registerCustomAction({ + id: 'example-custom-action', + type: AppMenuActionType.custom, + controlProps: { + label: 'Custom action', + testId: 'example-custom-action', + onClick: ({ onFinishAction }) => { + alert('Example Custom action clicked'); + onFinishAction(); // This allows to return focus back to the app menu DOM node + }, + }, + // In case of a submenu, you can add actions to it under `actions` + // actions: [ + // { + // id: 'example-custom-action-1-1', + // type: AppMenuActionType.custom, + // controlProps: { + // label: 'Custom action', + // onClick: ({ onFinishAction }) => { + // alert('Example Custom action clicked'); + // onFinishAction(); + // }, + // }, + // }, + // { + // id: 'example-custom-action-1-2', + // type: AppMenuActionType.submenuHorizontalRule + // }, + // ... + // ], + }); + + // This example shows how to add a custom action under the Alerts submenu + registry.registerCustomActionUnderSubmenu(AppMenuActionId.alerts, { + // It's also possible to override the submenu actions by using the same id + // as `AppMenuActionId.createRule` or `AppMenuActionId.manageRulesAndConnectors` + id: 'example-custom-action4', + type: AppMenuActionType.custom, + order: 101, + controlProps: { + label: 'Create SLO (Custom action)', + iconType: 'visGauge', + testId: 'example-custom-action-under-alerts', + onClick: ({ onFinishAction }) => { + // This is an example of a custom action that opens a flyout or any other custom modal. + // To do so, simply return a React element and call onFinishAction when you're done. + return ( + +
Example custom action clicked
+
+ ); + }, + }, + }); + + // This submenu was defined in the root profile example_root_pofile/profile.tsx + // And we can still add actions to it from the data source profile here. + registry.registerCustomActionUnderSubmenu('example-custom-root-submenu', { + id: 'example-custom-action5', + type: AppMenuActionType.custom, + controlProps: { + label: 'Custom action (from Data Source profile)', + onClick: ({ onFinishAction }) => { + alert('Example Data source action under root submenu clicked'); + onFinishAction(); + }, + }, + }); + + return prevValue.appMenuRegistry(registry); + }, + }; + }, + getRowAdditionalLeadingControls: (prev) => (params) => { + const additionalControls = prev(params) || []; + + return [ + ...additionalControls, + ...['visBarVerticalStacked', 'heart', 'inspect'].map( + (iconType): RowControlColumn => ({ + id: `exampleControl_${iconType}`, + render: (Control, rowProps) => { + return ( + { + alert(`Example "${iconType}" control clicked. Row index: ${rowProps.rowIndex}`); + }} + /> + ); + }, + }) + ), + ]; + }, + getDefaultAppState: () => () => ({ + breakdownField: 'log.level', + columns: [ + { + name: '@timestamp', + width: 212, + }, + { + name: 'log.level', + width: 150, + }, + { + name: 'message', + }, + ], + rowHeight: 5, + }), + getAdditionalCellActions: (prev) => () => + [ + ...prev(), + { + id: 'example-data-source-action', + getDisplayName: () => 'Example data source action', + getIconType: () => 'plus', + execute: () => { + alert('Example data source action executed'); + }, + }, + { + id: 'another-example-data-source-action', + getDisplayName: () => 'Another example data source action', + getIconType: () => 'minus', + execute: () => { + alert('Another example data source action executed'); + }, + isCompatible: ({ field }) => field.name !== 'message', + }, + ], + getPaginationConfig: (prev) => () => ({ + ...prev(), + paginationMode: 'singlePage', + }), + /** + * The `getRecommendedFields` extension point allows profiles to define fields that should be surfaced + * as recommended in the field list sidebar. These fields appear in a dedicated "Recommended Fields" section. + * This is useful for highlighting important fields for specific data source types. + * @param prev + */ + getRecommendedFields: (prev) => () => { + // Define example recommended field names for the example logs data source + const exampleRecommendedFieldNames: Array = [ + 'log.level', + 'message', + 'service.name', + 'host.name', + ]; + + return { + ...prev(), + recommendedFields: exampleRecommendedFieldNames, + }; + }, + getChartSectionConfiguration: (prev) => (params) => { + return { + ...prev(params), + renderChartSection: (props) => ( + + ), + localStorageKeyPrefix: 'discover:exampleDataSource', + replaceDefaultChart: true, + }; + }, + }, + resolve: (params) => { + const indexPattern = extractIndexPatternFrom(params); + + if (indexPattern !== 'my-example-logs' && indexPattern !== 'my-example-logs,logstash*') { + return { isMatch: false }; + } + + return { + isMatch: true, + context: { + category: DataSourceCategory.Logs, + formatRecord: (record) => JSON.stringify(record, null, 2), + }, + }; + }, +}); diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/index.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/index.ts new file mode 100644 index 0000000000000..cd27c9abe55f7 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { createExampleDocumentProfileProvider } from './profile'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/profile.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/profile.ts new file mode 100644 index 0000000000000..5752db8fde17e --- /dev/null +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_document_profile/profile.ts @@ -0,0 +1,30 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { getFieldValue } from '@kbn/discover-utils'; +import type { DocumentProfileProvider } from '../../../profiles'; +import { DocumentType } from '../../../profiles'; + +export const createExampleDocumentProfileProvider = (): DocumentProfileProvider => ({ + profileId: 'example-document-profile', + isExperimental: true, + profile: {}, + resolve: (params) => { + if (getFieldValue(params.record, 'data_stream.type') !== 'example') { + return { isMatch: false }; + } + + return { + isMatch: true, + context: { + type: DocumentType.Default, + }, + }; + }, +}); diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts new file mode 100644 index 0000000000000..b286a7d8cdce0 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { + createExampleRootProfileProvider, + createExampleSolutionViewRootProfileProvider, +} from './profile'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx new file mode 100644 index 0000000000000..627aebc6dfa31 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx @@ -0,0 +1,167 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + EuiBadge, + EuiCodeBlock, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import { AppMenuActionType, getFieldValue } from '@kbn/discover-utils'; +import React, { useState } from 'react'; +import type { RootProfileProvider } from '../../../profiles'; +import { SolutionType } from '../../../profiles'; +import { ExampleContextProvider } from '../example_context'; + +export const createExampleRootProfileProvider = (): RootProfileProvider => ({ + profileId: 'example-root-profile', + isExperimental: true, + profile: { + getRenderAppWrapper, + getDefaultAdHocDataViews, + getCellRenderers: (prev) => (params) => ({ + ...prev(params), + '@timestamp': (props) => { + const timestamp = getFieldValue(props.row, '@timestamp') as string; + + return ( + + {timestamp} + + ); + }, + }), + /** + * The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu. + * The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation. + * And it supports opening custom flyouts and any other modals on the click. + * `getAppMenu` can be configured in both root and data source profiles. + * @param prev + */ + getAppMenu: (prev) => (params) => { + const prevValue = prev(params); + + // Check `params` for the available deps + + return { + appMenuRegistry: (registry) => { + // Note: Only 2 custom actions are allowed to be rendered in the app menu. The rest will be ignored. + + // Register a custom submenu action + registry.registerCustomAction({ + id: 'example-custom-root-submenu', + type: AppMenuActionType.custom, + label: 'Custom Submenu', + testId: 'example-custom-root-submenu', + actions: [ + { + id: 'example-custom-root-action11', + type: AppMenuActionType.custom, + controlProps: { + label: 'Custom action 11 (from Root profile)', + testId: 'example-custom-root-action11', + onClick: ({ onFinishAction }) => { + alert('Example Root Custom action 11 clicked'); + onFinishAction(); // This allows to close the popover and return focus back to the app menu DOM node + }, + }, + }, + { + id: 'example-custom-root-action12', + type: AppMenuActionType.custom, + controlProps: { + label: 'Custom action 12 (from Root profile)', + testId: 'example-custom-root-action12', + onClick: ({ onFinishAction }) => { + // This is an example of a custom action that opens a flyout or any other custom modal. + // To do so, simply return a React element and call onFinishAction when you're done. + return ( + +
Example custom action clicked
+
+ ); + }, + }, + }, + ], + }); + + return prevValue.appMenuRegistry(registry); + }, + }; + }, + }, + resolve: (params) => { + if (params.solutionNavId != null) { + return { isMatch: false }; + } + + return { isMatch: true, context: { solutionType: SolutionType.Default } }; + }, +}); + +export const createExampleSolutionViewRootProfileProvider = (): RootProfileProvider => ({ + profileId: 'example-solution-view-root-profile', + isExperimental: true, + profile: { getRenderAppWrapper, getDefaultAdHocDataViews }, + resolve: (params) => ({ + isMatch: true, + context: { solutionType: params.solutionNavId as SolutionType }, + }), +}); + +const getRenderAppWrapper: RootProfileProvider['profile']['getRenderAppWrapper'] = + (PrevWrapper) => + ({ children }) => { + const [currentMessage, setCurrentMessage] = useState(undefined); + + return ( + + + {children} + {currentMessage && ( + setCurrentMessage(undefined)} + data-test-subj="exampleRootProfileFlyout" + > + + +

Inspect message

+
+
+ + + {currentMessage} + + +
+ )} +
+
+ ); + }; + +const getDefaultAdHocDataViews: RootProfileProvider['profile']['getDefaultAdHocDataViews'] = + (prev) => () => + [ + ...prev(), + { + id: 'example-root-profile-ad-hoc-data-view', + name: 'Example profile data view', + title: 'my-example-*', + timeFieldName: '@timestamp', + }, + ]; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_enabled_profile_providers.test.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_enabled_profile_providers.test.ts index ce88009085392..a447efb3575da 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_enabled_profile_providers.test.ts +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_enabled_profile_providers.test.ts @@ -14,9 +14,12 @@ import { createContextAwarenessMocks, createProfileProviderSharedServicesMock, } from '../__mocks__'; +import { createExampleRootProfileProvider } from './example/example_root_profile'; import { registerEnabledProfileProviders } from './register_enabled_profile_providers'; import type { CellRenderersExtensionParams } from '../types'; +const exampleRootProfileProvider = createExampleRootProfileProvider(); + describe('registerEnabledProfileProviders', () => { beforeEach(() => { jest.clearAllMocks(); @@ -44,6 +47,49 @@ describe('registerEnabledProfileProviders', () => { }); }); + it('should not register experimental profile providers by default', async () => { + jest.spyOn(exampleRootProfileProvider.profile, 'getCellRenderers'); + const profileProviderServices = createProfileProviderSharedServicesMock(); + const { rootProfileServiceMock } = createContextAwarenessMocks({ + shouldRegisterProviders: false, + }); + registerEnabledProfileProviders({ + profileService: rootProfileServiceMock, + providers: [exampleRootProfileProvider], + enabledExperimentalProfileIds: [], + services: profileProviderServices, + }); + const context = await rootProfileServiceMock.resolve({ solutionNavId: null }); + const profile = rootProfileServiceMock.getProfile({ context }); + const baseImpl = () => ({}); + profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect(exampleRootProfileProvider.profile.getCellRenderers).not.toHaveBeenCalled(); + expect(profile).toMatchObject({}); + }); + + it('should register experimental profile providers when enabled by config', async () => { + jest.spyOn(exampleRootProfileProvider.profile, 'getCellRenderers'); + const profileProviderServices = createProfileProviderSharedServicesMock(); + const { rootProfileServiceMock, rootProfileProviderMock } = createContextAwarenessMocks({ + shouldRegisterProviders: false, + }); + registerEnabledProfileProviders({ + profileService: rootProfileServiceMock, + providers: [exampleRootProfileProvider], + enabledExperimentalProfileIds: [exampleRootProfileProvider.profileId], + services: profileProviderServices, + }); + const context = await rootProfileServiceMock.resolve({ solutionNavId: null }); + const profile = rootProfileServiceMock.getProfile({ context }); + const baseImpl = () => ({}); + profile.getCellRenderers?.(baseImpl)({} as unknown as CellRenderersExtensionParams); + expect(exampleRootProfileProvider.profile.getCellRenderers).toHaveBeenCalledTimes(1); + expect(exampleRootProfileProvider.profile.getCellRenderers).toHaveBeenCalledWith(baseImpl, { + context, + }); + expect(rootProfileProviderMock.profile.getCellRenderers).not.toHaveBeenCalled(); + }); + it('should register restricted profile when product feature is available', async () => { const profileProviderServices = createProfileProviderSharedServicesMock(); const { rootProfileServiceMock, dataSourceProfileServiceMock, dataSourceProfileProviderMock } = diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts index 807d9645452ba..c43c0cf5eb2fb 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.test.ts @@ -8,7 +8,11 @@ */ import { uniq } from 'lodash'; +import { createEsqlDataSource } from '../../../common/data_sources'; import { createContextAwarenessMocks, createProfileProviderSharedServicesMock } from '../__mocks__'; +import { createExampleRootProfileProvider } from './example/example_root_profile'; +import { createExampleDataSourceProfileProvider } from './example/example_data_source_profile/profile'; +import { createExampleDocumentProfileProvider } from './example/example_document_profile'; import { registerProfileProviders } from './register_profile_providers'; import type { BaseProfileProvider } from '../profile_service'; @@ -36,6 +40,10 @@ jest.mock('./register_enabled_profile_providers', () => { }; }); +const exampleRootProfileProvider = createExampleRootProfileProvider(); +const exampleDataSourceProfileProvider = createExampleDataSourceProfileProvider(); +const exampleDocumentProfileProvider = createExampleDocumentProfileProvider(); + describe('registerProfileProviders', () => { beforeEach(() => { mockAllCollectedProfiles = []; @@ -51,10 +59,32 @@ describe('registerProfileProviders', () => { rootProfileService: rootProfileServiceMock, dataSourceProfileService: dataSourceProfileServiceMock, documentProfileService: documentProfileServiceMock, - enabledExperimentalProfileIds: [], + enabledExperimentalProfileIds: [ + exampleRootProfileProvider.profileId, + exampleDataSourceProfileProvider.profileId, + exampleDocumentProfileProvider.profileId, + ], sharedServices: profileProviderServices, services: profileProviderServices, }); + const rootContext = await rootProfileServiceMock.resolve({ solutionNavId: null }); + const dataSourceContext = await dataSourceProfileServiceMock.resolve({ + rootContext, + dataSource: createEsqlDataSource(), + query: { esql: 'from my-example-logs' }, + }); + const documentContext = documentProfileServiceMock.resolve({ + rootContext, + dataSourceContext, + record: { + id: 'test', + flattened: { 'data_stream.type': 'example' }, + raw: {}, + }, + }); + expect(rootContext.profileId).toBe(exampleRootProfileProvider.profileId); + expect(dataSourceContext.profileId).toBe(exampleDataSourceProfileProvider.profileId); + expect(documentContext.profileId).toBe(exampleDocumentProfileProvider.profileId); }); it('should not register disabled experimental profile providers', async () => { @@ -71,6 +101,24 @@ describe('registerProfileProviders', () => { sharedServices: profileProviderServices, services: profileProviderServices, }); + const rootContext = await rootProfileServiceMock.resolve({ solutionNavId: null }); + const dataSourceContext = await dataSourceProfileServiceMock.resolve({ + rootContext, + dataSource: createEsqlDataSource(), + query: { esql: 'from my-example-logs' }, + }); + const documentContext = documentProfileServiceMock.resolve({ + rootContext, + dataSourceContext, + record: { + id: 'test', + flattened: { 'data_stream.type': 'example' }, + raw: {}, + }, + }); + expect(rootContext.profileId).not.toBe(exampleRootProfileProvider.profileId); + expect(dataSourceContext.profileId).not.toBe(exampleDataSourceProfileProvider.profileId); + expect(documentContext.profileId).not.toBe(exampleDocumentProfileProvider.profileId); }); it('all profile ids should be unique', async () => { diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.ts index 2f371fc26fc02..8b1b4da9842a1 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.ts +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/register_profile_providers.ts @@ -17,6 +17,12 @@ import { createClassicNavRootProfileProvider } from './common/classic_nav_root_p import { createDeprecationLogsDataSourceProfileProvider } from './common/deprecation_logs_data_source_profile'; import { createPatternsDataSourceProfileProvider } from './common/patterns_data_source_profile'; import { registerEnabledProfileProviders } from './register_enabled_profile_providers'; +import { createExampleDataSourceProfileProvider } from './example/example_data_source_profile/profile'; +import { createExampleDocumentProfileProvider } from './example/example_document_profile'; +import { + createExampleRootProfileProvider, + createExampleSolutionViewRootProfileProvider, +} from './example/example_root_profile'; import { createObservabilityLogsDataSourceProfileProviders } from './observability/logs_data_source_profile'; import { createObservabilityDocumentProfileProviders } from './observability/observability_profile_providers'; import { createObservabilityRootProfileProvider } from './observability/observability_root_profile/profile'; @@ -99,6 +105,8 @@ export const registerProfileProviders = ({ * @returns An array of available root profile providers */ const createRootProfileProviders = (providerServices: ProfileProviderServices) => [ + createExampleRootProfileProvider(), + createExampleSolutionViewRootProfileProvider(), createClassicNavRootProfileProvider(providerServices), createSecurityRootProfileProvider(providerServices), createObservabilityRootProfileProvider(providerServices), @@ -110,6 +118,7 @@ const createRootProfileProviders = (providerServices: ProfileProviderServices) = * @returns An array of available data source profile providers */ const createDataSourceProfileProviders = (providerServices: ProfileProviderServices) => [ + createExampleDataSourceProfileProvider(), createPatternsDataSourceProfileProvider(providerServices), createDeprecationLogsDataSourceProfileProvider(), ...createObservabilityLogsDataSourceProfileProviders(providerServices), @@ -123,6 +132,7 @@ const createDataSourceProfileProviders = (providerServices: ProfileProviderServi * @returns An array of available document profile providers */ const createDocumentProfileProviders = (providerServices: ProfileProviderServices) => [ + createExampleDocumentProfileProvider(), createSecurityDocumentProfileProvider(providerServices), ...createObservabilityDocumentProfileProviders(providerServices), ]; From 3403cb483b5543812d5147ab181d7ca8c8924336 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 15 Jan 2026 23:56:31 +0100 Subject: [PATCH 23/76] Add collapse and triggerElement to app menu --- .../core-chrome-app-menu-components/index.ts | 1 + .../src/components/app_menu.tsx | 39 ++++++++++++------- .../app_menu_action_button.test.tsx | 1 - .../src/components/app_menu_action_button.tsx | 10 ++--- .../src/components/app_menu_item.tsx | 6 +-- .../src/index.ts | 1 + .../src/types.ts | 10 ++++- .../src/utils.tsx | 6 +-- 8 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts index 71b8722e534d8..15a7bba5401cd 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts @@ -15,6 +15,7 @@ export { AppMenuPopover } from './src'; export { AppMenuPopoverActionButtons } from './src'; export type { + AppMenuRunAction, AppMenuConfig, AppMenuItemType, AppMenuSecondaryActionItem, diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu.tsx index 7d8cb0f8c0854..7971beb12d9c9 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu.tsx @@ -18,12 +18,21 @@ import type { AppMenuConfig } from '../types'; export interface AppMenuItemsProps { config?: AppMenuConfig; visible?: boolean; + /** + * Whether to render the app menu in a collapsed state (showing only the overflow button). + * Only available for the standalone app menu component. + */ + isCollapsed?: boolean; } const hasNoItems = (config: AppMenuConfig) => !config.items?.length && !config?.primaryActionItem && !config?.secondaryActionItem; -export const AppMenuComponent = ({ config, visible = true }: AppMenuItemsProps) => { +export const AppMenuComponent = ({ + config, + visible = true, + isCollapsed = false, +}: AppMenuItemsProps) => { const [openPopoverId, setOpenPopoverId] = useState(null); const isBetweenMandXlBreakpoint = useIsWithinBreakpoints(['m', 'l']); const isAboveXlBreakpoint = useIsWithinBreakpoints(['xl']); @@ -77,6 +86,21 @@ export const AppMenuComponent = ({ config, visible = true }: AppMenuItemsProps) /> ) : undefined; + const collapsedComponent = ( + handlePopoverToggle(showMoreButtonId)} + onPopoverClose={handleOnPopoverClose} + /> + ); + + if (isCollapsed) { + return {collapsedComponent}; + } + if (isBetweenMandXlBreakpoint) { return ( @@ -119,16 +143,5 @@ export const AppMenuComponent = ({ config, visible = true }: AppMenuItemsProps) ); } - return ( - - handlePopoverToggle(showMoreButtonId)} - onPopoverClose={handleOnPopoverClose} - /> - - ); + return {collapsedComponent}; }; diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.test.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.test.tsx index a507ac3114e09..7c6c3fef309f3 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.test.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.test.tsx @@ -46,7 +46,6 @@ describe('AppMenuActionButton', () => { await user.click(screen.getByTestId('test-action-button')); expect(defaultProps.run).toHaveBeenCalledTimes(1); - expect(defaultProps.run).toHaveBeenCalledWith(); }); it('should render as split button', () => { diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx index 3a176e7c814e3..a5b00d9343409 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; +import React, { type MouseEvent } from 'react'; import { SplitButtonWithNotification } from '@kbn/split-button'; import { upperFirst } from 'lodash'; import type { EuiButtonColor, PopoverAnchorPosition } from '@elastic/eui'; @@ -78,7 +78,7 @@ export const AppMenuActionButton = (props: AppMenuActionButtonProps) => { const hasItems = items && items.length > 0; const hasSplitItems = splitButtonItems && splitButtonItems.length > 0; - const handleClick = () => { + const handleClick = (event: MouseEvent) => { if (isDisabled(disableButton)) return; if (hasItems) { @@ -86,10 +86,10 @@ export const AppMenuActionButton = (props: AppMenuActionButtonProps) => { return; } - run?.(); + run?.(event.currentTarget); }; - const handleSecondaryButtonClick = () => { + const handleSecondaryButtonClick = (event: MouseEvent) => { if (isDisabled(splitButtonProps?.isSecondaryButtonDisabled)) return; if (hasSplitItems) { @@ -97,7 +97,7 @@ export const AppMenuActionButton = (props: AppMenuActionButtonProps) => { return; } - splitButtonRun?.(); + splitButtonRun?.(event.currentTarget); }; const commonProps = { diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx index 8a8519e9d3f3a..86a7f9be88f2f 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; +import React, { type MouseEvent } from 'react'; import { EuiHeaderLink, EuiHideFor, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { upperFirst } from 'lodash'; import { css } from '@emotion/react'; @@ -49,7 +49,7 @@ export const AppMenuItem = ({ const showTooltip = Boolean(content || title); const hasItems = items && items.length > 0; - const handleClick = () => { + const handleClick = (event: MouseEvent) => { if (isDisabled(disableButton)) return; if (hasItems) { @@ -57,7 +57,7 @@ export const AppMenuItem = ({ return; } - run?.(); + run?.(event.currentTarget); }; const buttonCss = css` diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts index 2f26b14e1ab22..34b2081be7e65 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts @@ -15,6 +15,7 @@ export { AppMenuPopover } from './components'; export { AppMenuPopoverActionButtons } from './components'; export type { + AppMenuRunAction, AppMenuConfig, AppMenuItemType, AppMenuSecondaryActionItem, diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts index 1ecc8d58b2e94..4ae2992f9a0ca 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts @@ -10,6 +10,12 @@ import type { EuiButtonColor, EuiButtonProps, EuiHideForProps, IconType } from '@elastic/eui'; import type { SplitButtonWithNotificationProps } from '@kbn/split-button'; +/** + * Type for the function that runs when an app menu item is clicked + * @param triggerElement - The HTML element that triggered the action. Do not use this to open popovers. Use `items` property to define popover items instead. + */ +export type AppMenuRunAction = (triggerElement: HTMLElement) => void; + /** * Subset of SplitButtonWithNotificationProps. */ @@ -42,7 +48,7 @@ export type AppMenuSplitButtonProps = /** * Function to run when the item is clicked. Only used if `items` is not provided. */ - run: () => void; + run: AppMenuRunAction; }) | (BaseSplitProps & { /** @@ -114,7 +120,7 @@ export type AppMenuItemCommon = /** * Function to run when the item is clicked. Only used if `items` is not provided. */ - run: () => void; + run: AppMenuRunAction; /** * Sub-items to show in a popover when the item is clicked. Only used if `run` is not provided. */ diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx index e76267dc28a4c..c27eb082e02ff 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; +import React, { type MouseEvent } from 'react'; import { isArray, isFunction, upperFirst } from 'lodash'; import { type EuiButtonColor, @@ -117,7 +117,7 @@ export const mapAppMenuItemToPanelItem = ( tooltipTitle: item?.tooltipTitle, }); - const handleClick = () => { + const handleClick = (event: MouseEvent) => { if (isDisabled(item?.disableButton)) { return; } @@ -125,7 +125,7 @@ export const mapAppMenuItemToPanelItem = ( const shouldClosePopover = !item?.href && childPanelId === undefined && item.run?.length === 0 && onClose; - item.run?.(); + item.run?.(event?.currentTarget as HTMLElement); if (shouldClosePopover) { onClose(); From a70e699d3925d761c51426966d11c300041db642 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Fri, 16 Jan 2026 01:03:32 +0100 Subject: [PATCH 24/76] Context awarness fixes --- .../components/app_menu/app_menu_registry.ts | 33 ++++- .../top_nav/app_menu_actions/get_alerts.tsx | 40 +++--- .../top_nav/app_menu_actions/index.ts | 1 + .../run_app_menu_action.test.tsx | 121 ++++++++++++++++++ .../app_menu_actions/run_app_menu_action.tsx | 111 ++++++++++++++++ .../top_nav/app_menu_actions/types.ts | 10 ++ .../components/top_nav/use_top_nav_links.tsx | 17 ++- .../example/example_root_profile/profile.tsx | 55 ++++---- 8 files changed, 330 insertions(+), 58 deletions(-) create mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx create mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts index 8d9e8c425c8c7..1e44581f1f5a3 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts @@ -21,10 +21,37 @@ import type { * primary/secondary actions, and popover items for specific parent items. */ export class AppMenuRegistry { + static CUSTOM_ITEMS_LIMIT = 2; private items: Map = new Map(); + private customItems: Map = new Map(); private primaryActionItem?: AppMenuPrimaryActionItem; private secondaryActionItem?: AppMenuSecondaryActionItem; + private getCustomItems(): AppMenuItemType[] { + return Array.from(this.customItems.values()).slice(0, AppMenuRegistry.CUSTOM_ITEMS_LIMIT); + } + + /** + * Register a custom menu item. + * @param item The menu item to register + */ + public registerCustomItem(item: AppMenuItemType) { + this.customItems.set(item.id, item as AppMenuItemType); + } + + /** + * Register a custom menu item. + * @param item The menu item to register + */ + public registerCustomPopoverItem(parentId: string, popoverItem: AppMenuPopoverItem) { + this.customItems.set(parentId, { + ...this.customItems.get(parentId), + items: [...(this.customItems.get(parentId)?.items || []), popoverItem].sort( + (a: AppMenuPopoverItem, b: AppMenuPopoverItem) => (a.order || 0) - (b.order || 0) + ), + } as AppMenuItemType); + } + /** * Register a menu item. * @param item The menu item to register @@ -77,9 +104,9 @@ export class AppMenuRegistry { */ public getAppMenuConfig(): AppMenuConfig { return { - items: Array.from(this.items.values()).sort( - (a: AppMenuItemType, b: AppMenuItemType) => (a.order || 0) - (b.order || 0) - ), + items: Array.from(this.items.values()) + .concat(this.getCustomItems()) + .sort((a: AppMenuItemType, b: AppMenuItemType) => (a.order || 0) - (b.order || 0)), primaryActionItem: this.primaryActionItem, secondaryActionItem: this.secondaryActionItem, }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx index 8c7cbe04ba00a..943c301c00ade 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx @@ -14,13 +14,11 @@ import { AppMenuActionId } from '@kbn/discover-utils'; import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils'; import { AlertConsumers, ES_QUERY_ID, STACK_ALERTS_FEATURE_ID } from '@kbn/rule-data-utils'; import type { RuleTypeMetaData } from '@kbn/alerting-plugin/common'; -import { RuleFormFlyoutContent } from '@kbn/response-ops-rule-form/flyout'; +import { RuleFormFlyout } from '@kbn/response-ops-rule-form/flyout'; import { isValidRuleFormPlugins } from '@kbn/response-ops-rule-form/lib'; -import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; -import { toMountPoint } from '@kbn/react-kibana-mount'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { AppMenuItemType, AppMenuPopoverItem } from '@kbn/core-chrome-app-menu-components'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; -import type { AppMenuDiscoverParams } from './types'; +import type { AppMenuDiscoverParams, DiscoverAppMenuRunAction } from './types'; import type { DiscoverServices } from '../../../../../build_services'; const EsQueryValidConsumer: RuleCreationValidConsumer[] = [ @@ -35,14 +33,14 @@ interface EsQueryAlertMetaData extends RuleTypeMetaData { adHocDataViewList: DataView[]; } -const RuleFormFlyoutWithType = RuleFormFlyoutContent; +const RuleFormFlyoutWithType = RuleFormFlyout; const CreateAlertFlyout: React.FC<{ discoverParams: AppMenuDiscoverParams; services: DiscoverServices; - onFinishAction: () => void; + onFinishAction?: () => void; stateContainer: DiscoverStateContainer; -}> = ({ stateContainer, discoverParams, services, onFinishAction }) => { +}> = ({ stateContainer, discoverParams, services, onFinishAction = () => {} }) => { const { dataView, isEsqlMode, @@ -157,21 +155,16 @@ export const getAlertsAppMenuItem = ({ : i18n.translate('discover.alerts.missedTimeFieldToolTip', { defaultMessage: 'Data view does not have a time field.', }), - run: () => { - const overlay = services.core.overlays.openFlyout( - toMountPoint( - - overlay.close()} - stateContainer={stateContainer} - /> - , - services.core - ) + run: (async (_triggerElement, onFinishAction) => { + return ( + ); - }, + }) as DiscoverAppMenuRunAction, }); } } @@ -185,7 +178,8 @@ export const getAlertsAppMenuItem = ({ order: 4, iconType: 'alert', popoverWidth: 250, - items, + // Cast needed because Discover extends AppMenuRunAction with onFinishAction callback + items: items as AppMenuPopoverItem[], }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/index.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/index.ts index b6582d1f62e16..1eed79b3257e0 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/index.ts @@ -13,4 +13,5 @@ export { getOpenSearchAppMenuItem } from './get_open_search'; export { getShareAppMenuItem } from './get_share'; export { getInspectAppMenuItem } from './get_inspect'; export { getBackgroundSearchFlyout } from './get_background_search_flyout'; +export { runAppMenuAction, enhanceAppMenuItemWithRunAction } from './run_app_menu_action'; export type * from './types'; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx new file mode 100644 index 0000000000000..fe973ce06c90f --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx @@ -0,0 +1,121 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import type { AppMenuActionSubmenuCustom, AppMenuItem } from '@kbn/discover-utils'; +import { AppMenuActionType } from '@kbn/discover-utils'; +import { discoverServiceMock } from '../../../../../__mocks__/services'; +import { runAppMenuAction, runAppMenuPopoverAction } from './run_app_menu_action'; + +describe('run app menu actions', () => { + describe('runAppMenuAction', () => { + it('should call the action correctly', () => { + const appMenuItem: AppMenuItem = { + id: 'action-1', + type: AppMenuActionType.primary, + controlProps: { + label: 'Action 1', + testId: 'action-1', + iconType: 'share', + onClick: jest.fn(), + }, + }; + + const anchorElement = document.createElement('div'); + + runAppMenuAction({ + appMenuItem, + anchorElement, + services: discoverServiceMock, + }); + + expect(appMenuItem.controlProps.onClick).toHaveBeenCalled(); + }); + + it('should call the action and render a custom content', async () => { + const appMenuItem: AppMenuItem = { + id: 'action-1', + type: AppMenuActionType.primary, + controlProps: { + label: 'Action 1', + testId: 'action-1', + iconType: 'share', + onClick: jest.fn(({ onFinishAction }) => ( +
diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx index 6dc07e5db0d20..90f7abd584f33 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx @@ -9,19 +9,19 @@ import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { ES_QUERY_ID } from '@kbn/rule-data-utils'; -import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; import { getAlertsAppMenuItem } from './get_alerts'; import { discoverServiceMock } from '../../../../../__mocks__/services'; import { dataViewWithTimefieldMock } from '../../../../../__mocks__/data_view_with_timefield'; import { dataViewWithNoTimefieldMock } from '../../../../../__mocks__/data_view_no_timefield'; import { getDiscoverStateMock } from '../../../../../__mocks__/discover_state.mock'; import type { AppMenuExtensionParams } from '../../../../../context_awareness'; +import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; const getAlertsMenuItem = ( dataView = dataViewMock, isEsqlMode = false, authorizedRuleTypeIds = [ES_QUERY_ID] -): AppMenuItemType => { +): DiscoverAppMenuItemType => { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); stateContainer.actions.setDataView(dataView); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx index c235642aa289e..89fb302e6a423 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx @@ -16,7 +16,7 @@ import { AlertConsumers, ES_QUERY_ID, STACK_ALERTS_FEATURE_ID } from '@kbn/rule- import type { RuleTypeMetaData } from '@kbn/alerting-plugin/common'; import { RuleFormFlyout } from '@kbn/response-ops-rule-form/flyout'; import { isValidRuleFormPlugins } from '@kbn/response-ops-rule-form/lib'; -import type { AppMenuItemType, AppMenuRunActionParams } from '@kbn/core-chrome-app-menu-components'; +import type { DiscoverAppMenuItemType, DiscoverAppMenuPopoverItem } from '@kbn/discover-utils'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; import type { AppMenuDiscoverParams } from './types'; import type { DiscoverServices } from '../../../../../build_services'; @@ -119,12 +119,12 @@ export const getAlertsAppMenuItem = ({ discoverParams: AppMenuDiscoverParams; services: DiscoverServices; stateContainer: DiscoverStateContainer; -}): AppMenuItemType => { +}): DiscoverAppMenuItemType => { const { dataView, isEsqlMode } = discoverParams; const timeField = getTimeField(dataView); const hasTimeFieldName = !isEsqlMode ? Boolean(dataView?.timeFieldName) : Boolean(timeField); - const items = []; + const items: DiscoverAppMenuPopoverItem[] = []; if (services.capabilities.management?.insightsAndAlerting?.triggersActions) { items.push({ @@ -155,20 +155,10 @@ export const getAlertsAppMenuItem = ({ : i18n.translate('discover.alerts.missedTimeFieldToolTip', { defaultMessage: 'Data view does not have a time field.', }), - run: (params?: AppMenuRunActionParams) => { - const onFinishAction = () => { - const contextCallback = params?.context?.onFinishAction as (() => void) | undefined; - contextCallback?.(); - // Focus the main alerts button after flyout closes - const alertsButton = document.querySelector( - '[data-test-subj="discoverAlertsButton"]' - ) as HTMLElement; - alertsButton?.focus(); - }; - + run: (params) => { return ( void; -}): AppMenuItemType => { + onOpenInspector: (onClose?: () => void) => void; +}): DiscoverAppMenuItemType => { return { id: 'inspect', iconType: 'inspect', @@ -23,8 +23,8 @@ export const getInspectAppMenuItem = ({ defaultMessage: 'Inspect', }), testId: 'openInspectorButton', - run: () => { - onOpenInspector(); + run: (params) => { + onOpenInspector(params.context.onFinishAction); }, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx index cd1b580a1e1b7..090bb7f4861df 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; +import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; import { AppMenuActionId } from '@kbn/discover-utils'; import { i18n } from '@kbn/i18n'; @@ -17,7 +17,7 @@ export const getNewSearchAppMenuItem = ({ }: { onNewSearch: () => void; onNavigate: () => void; -}): AppMenuItemType => { +}): DiscoverAppMenuItemType => { return { id: AppMenuActionId.new, order: 1, diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx index 27b69a5e934d7..4eca29edccad6 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx @@ -8,16 +8,16 @@ */ import React from 'react'; +import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; import { AppMenuActionId } from '@kbn/discover-utils'; import { i18n } from '@kbn/i18n'; -import type { AppMenuItemType, AppMenuRunActionParams } from '@kbn/core-chrome-app-menu-components'; import { OpenSearchPanel } from '../open_search_panel'; export const getOpenSearchAppMenuItem = ({ onOpenSavedSearch, }: { onOpenSavedSearch: (savedSearchId: string) => void; -}): AppMenuItemType => { +}): DiscoverAppMenuItemType => { return { id: AppMenuActionId.open, order: 2, @@ -26,8 +26,8 @@ export const getOpenSearchAppMenuItem = ({ }), iconType: 'folderOpen', testId: 'discoverOpenButton', - run: (params?: AppMenuRunActionParams) => { - const onFinishAction = params?.context?.onFinishAction as () => void; + run: (params) => { + const onFinishAction = params?.context.onFinishAction; return ; }, }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx index 5ca4b24323f80..4a14eae825d47 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx @@ -8,15 +8,16 @@ */ import React from 'react'; -import type { AppMenuItemType, AppMenuRunActionParams } from '@kbn/core-chrome-app-menu-components'; +import type { AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; import { discoverServiceMock } from '../../../../../__mocks__/services'; import { runAppMenuAction, enhanceAppMenuItemWithRunAction } from './run_app_menu_action'; +import type { DiscoverAppMenuItemType, DiscoverAppMenuRunActionParams } from '@kbn/discover-utils'; describe('run app menu actions', () => { describe('runAppMenuAction', () => { it('should call the run function with correct params', async () => { const mockRun = jest.fn(); - const appMenuItem: AppMenuItemType = { + const appMenuItem: DiscoverAppMenuItemType = { id: 'action-1', order: 1, label: 'Action 1', @@ -69,14 +70,14 @@ describe('run app menu actions', () => { }); it('should call onFinishAction to cleanup', async () => { - let capturedParams: AppMenuRunActionParams | undefined; + let capturedParams: DiscoverAppMenuRunActionParams | undefined; const mockRun = jest.fn((params) => { capturedParams = params; return
Custom Content
; }); - const appMenuItem: AppMenuItemType = { + const appMenuItem: DiscoverAppMenuItemType = { id: 'action-1', order: 1, label: 'Action 1', @@ -86,6 +87,7 @@ describe('run app menu actions', () => { }; const anchorElement = document.createElement('div'); + document.body.appendChild(anchorElement); const focusSpy = jest.spyOn(anchorElement, 'focus'); await runAppMenuAction({ @@ -96,18 +98,18 @@ describe('run app menu actions', () => { expect(mockRun).toHaveBeenCalled(); expect(capturedParams).toBeDefined(); - expect(capturedParams?.context?.onFinishAction).toBeDefined(); + expect(capturedParams?.context.onFinishAction).toBeDefined(); // Container should be added const containers = document.body.querySelectorAll('div'); expect(containers.length).toBeGreaterThan(0); // Call onFinishAction to cleanup - const onFinishAction = capturedParams?.context?.onFinishAction as (() => void) | undefined; - onFinishAction?.(); + const onFinishAction = capturedParams?.context?.onFinishAction; + onFinishAction!(); - // Container should be removed - expect(document.body.querySelectorAll('div').length).toBe(0); + // Container should be removed (anchorElement still in DOM) + expect(document.body.querySelectorAll('div').length).toBe(1); expect(focusSpy).toHaveBeenCalled(); }); }); @@ -115,7 +117,7 @@ describe('run app menu actions', () => { describe('enhanceAppMenuItemWithRunAction', () => { it('should wrap the run function', () => { const mockRun = jest.fn(); - const appMenuItem: AppMenuItemType = { + const appMenuItem: DiscoverAppMenuItemType = { id: 'action-1', order: 1, label: 'Action 1', @@ -135,7 +137,7 @@ describe('run app menu actions', () => { it('should call runAppMenuAction when wrapper is invoked', async () => { const mockRun = jest.fn(); - const appMenuItem: AppMenuItemType = { + const appMenuItem: DiscoverAppMenuItemType = { id: 'action-1', order: 1, label: 'Action 1', @@ -150,7 +152,7 @@ describe('run app menu actions', () => { }); const triggerElement = document.createElement('div'); - enhanced.run?.({ triggerElement }); + enhanced.run?.({ triggerElement, context: { onFinishAction: jest.fn() } }); // Wait for async execution await new Promise((resolve) => setTimeout(resolve, 0)); @@ -160,7 +162,7 @@ describe('run app menu actions', () => { it('should recursively enhance nested items', () => { const mockNestedRun = jest.fn(); - const appMenuItem: AppMenuItemType = { + const appMenuItem: DiscoverAppMenuItemType = { id: 'parent', order: 1, label: 'Parent', @@ -179,7 +181,7 @@ describe('run app menu actions', () => { const enhanced = enhanceAppMenuItemWithRunAction({ appMenuItem, services: discoverServiceMock, - }); + }) as DiscoverAppMenuItemType; expect(enhanced.items).toBeDefined(); expect(enhanced.items?.[0]).toBeDefined(); @@ -188,7 +190,7 @@ describe('run app menu actions', () => { }); it('should preserve all properties', () => { - const appMenuItem: AppMenuItemType = { + const appMenuItem: DiscoverAppMenuItemType = { id: 'action-1', order: 5, label: 'Action 1', @@ -200,7 +202,7 @@ describe('run app menu actions', () => { const enhanced = enhanceAppMenuItemWithRunAction({ appMenuItem, services: discoverServiceMock, - }); + }) as DiscoverAppMenuItemType; expect(enhanced.id).toBe('action-1'); expect(enhanced.order).toBe(5); @@ -210,7 +212,7 @@ describe('run app menu actions', () => { }); it('should return undefined run when item has no run', () => { - const appMenuItem: AppMenuItemType = { + const appMenuItem: DiscoverAppMenuItemType = { id: 'action-1', order: 1, label: 'Action 1', diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx index 967944588aaca..fd443d9be8a3d 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx @@ -20,49 +20,91 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { AppMenuRunActionParams } from '@kbn/core-chrome-app-menu-components'; import type { - AppMenuItemType, - AppMenuPopoverItem, - AppMenuPrimaryActionItem, - AppMenuSecondaryActionItem, - AppMenuRunActionParams, -} from '@kbn/core-chrome-app-menu-components'; + DiscoverAppMenuItemType, + DiscoverAppMenuPopoverItem, + DiscoverAppMenuPrimaryActionItem, + DiscoverAppMenuRunActionParams, + DiscoverAppMenuSecondaryActionItem, +} from '@kbn/discover-utils'; import type { DiscoverServices } from '../../../../../build_services'; const container = document.createElement('div'); let isOpen = false; -function cleanup(anchorElement?: HTMLElement) { +function cleanup(anchorElement?: HTMLElement, parentTestId?: string) { if (!isOpen) { return; } + + // Check if anchor is in DOM before we remove the container + const shouldFocusAnchor = anchorElement && document.body.contains(anchorElement); + ReactDOM.unmountComponentAtNode(container); document.body.removeChild(container); isOpen = false; - anchorElement?.focus(); + + // Restore focus using the captured state + const overflowButton = document.querySelector( + '[data-test-subj="app-menu-overflow-button"]' + ) as HTMLElement; + + if (parentTestId) { + const parentButton = document.querySelector( + `[data-test-subj="${parentTestId}"]` + ) as HTMLElement; + (parentButton || overflowButton)?.focus(); + } else if (shouldFocusAnchor) { + anchorElement!.focus(); + } else { + overflowButton?.focus(); + } } export async function runAppMenuAction({ appMenuItem, anchorElement, services, + parentTestId, }: { appMenuItem: - | AppMenuItemType - | AppMenuPrimaryActionItem - | AppMenuSecondaryActionItem - | AppMenuPopoverItem; + | DiscoverAppMenuItemType + | DiscoverAppMenuPrimaryActionItem + | DiscoverAppMenuSecondaryActionItem + | DiscoverAppMenuPopoverItem; anchorElement: HTMLElement; services: DiscoverServices; + parentTestId?: string; }) { - cleanup(anchorElement); + cleanup(anchorElement, parentTestId); - const onFinishAction = () => cleanup(anchorElement); + const onFinishAction = () => { + cleanup(anchorElement, parentTestId); + // If cleanup didn't run (no React element), still restore focus + if (!isOpen) { + const overflowButton = document.querySelector( + '[data-test-subj="app-menu-overflow-button"]' + ) as HTMLElement; - const params: AppMenuRunActionParams = { + if (parentTestId) { + const parentButton = document.querySelector( + `[data-test-subj="${parentTestId}"]` + ) as HTMLElement; + (parentButton || overflowButton)?.focus(); + } else if (anchorElement && document.body.contains(anchorElement)) { + anchorElement.focus(); + } else { + overflowButton?.focus(); + } + } + }; + + const params: DiscoverAppMenuRunActionParams = { triggerElement: anchorElement, context: { onFinishAction, + parentTestId, }, }; @@ -83,25 +125,35 @@ export async function runAppMenuAction({ ReactDOM.render(element, container); } -export const enhanceAppMenuItemWithRunAction = < - T extends AppMenuItemType | AppMenuPrimaryActionItem | AppMenuSecondaryActionItem ->({ +export const enhanceAppMenuItemWithRunAction = ({ appMenuItem, services, + parentTestId, }: { - appMenuItem: T; + appMenuItem: + | DiscoverAppMenuItemType + | DiscoverAppMenuPrimaryActionItem + | DiscoverAppMenuSecondaryActionItem; services: DiscoverServices; -}): T => { - const itemWithItems = appMenuItem as AppMenuItemType; + parentTestId?: string; +}): + | DiscoverAppMenuItemType + | DiscoverAppMenuPrimaryActionItem + | DiscoverAppMenuSecondaryActionItem => { + const itemWithItems = appMenuItem as DiscoverAppMenuPopoverItem; return { ...appMenuItem, - // Recursively enhance nested items if present - items: itemWithItems.items?.map((nestedItem: AppMenuPopoverItem) => - enhanceAppMenuItemWithRunAction({ - appMenuItem: nestedItem as AppMenuItemType, - services, - }) + items: itemWithItems.items?.map( + (nestedItem) => + enhanceAppMenuItemWithRunAction({ + appMenuItem: nestedItem as + | DiscoverAppMenuItemType + | DiscoverAppMenuPrimaryActionItem + | DiscoverAppMenuSecondaryActionItem, + services, + parentTestId: appMenuItem.testId || 'app-menu-overflow-button', + }) as DiscoverAppMenuPopoverItem ), run: appMenuItem.run ? (params?: AppMenuRunActionParams) => { @@ -110,9 +162,10 @@ export const enhanceAppMenuItemWithRunAction = < appMenuItem, anchorElement: params.triggerElement, services, + parentTestId, }); } } : undefined, - } as T; + }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx index f89a37d8108a7..147ac13f45c83 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx @@ -16,7 +16,7 @@ import React, { } from 'react'; import { BehaviorSubject } from 'rxjs'; import useUnmount from 'react-use/lib/useUnmount'; -import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; +import type { DiscoverAppMenuConfig } from '@kbn/discover-utils'; import type { useDiscoverTopNav } from './use_discover_topnav'; /** @@ -27,7 +27,7 @@ import type { useDiscoverTopNav } from './use_discover_topnav'; */ const createTopNavMenuContext = () => ({ - topNavMenu$: new BehaviorSubject(undefined), + topNavMenu$: new BehaviorSubject(undefined), }); type DiscoverTopNavMenuContext = ReturnType; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index b2db1fa93af24..1b0a96e232fc6 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; import { BehaviorSubject } from 'rxjs'; import { useTopNavLinks } from './use_top_nav_links'; import type { DiscoverServices } from '../../../../build_services'; @@ -175,8 +176,8 @@ describe('useTopNavLinks', () => { expect(exportItem?.label).toBe('Export'); // Export should have popover items - expect(exportItem?.items).toBeDefined(); - expect(exportItem?.items?.length).toBeGreaterThan(0); + expect((exportItem as DiscoverAppMenuItemType)?.items).toBeDefined(); + expect((exportItem as DiscoverAppMenuItemType)?.items?.length).toBeGreaterThan(0); const shareItem = appMenuConfig.items?.find((item) => item.id === 'share'); expect(shareItem).toBeDefined(); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 7f0fd29b1d2f8..bbb3d0c361839 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -12,6 +12,12 @@ import { i18n } from '@kbn/i18n'; import type { DataView } from '@kbn/data-views-plugin/public'; import { METRIC_TYPE } from '@kbn/analytics'; import { ENABLE_ESQL, getInitialESQLQuery } from '@kbn/esql-utils'; +import type { + DiscoverAppMenuItemType, + DiscoverAppMenuConfig, + DiscoverAppMenuPrimaryActionItem, + DiscoverAppMenuSecondaryActionItem, +} from '@kbn/discover-utils'; import { AppMenuRegistry, dismissFlyouts, DiscoverFlyouts } from '@kbn/discover-utils'; import { ESQL_TYPE } from '@kbn/data-view-utils'; import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics'; @@ -19,7 +25,6 @@ import type { RuleTypeWithDescription } from '@kbn/alerts-ui-shared'; import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared'; import useObservable from 'react-use/lib/useObservable'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; -import type { AppMenuConfig, AppMenuItemType } from '@kbn/core-chrome-app-menu-components'; import { useI18n } from '@kbn/i18n-react'; import { createDataViewDataSource } from '../../../../../common/data_sources'; import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; @@ -76,7 +81,7 @@ export const useTopNavLinks = ({ shouldShowESQLToDataViewTransitionModal: boolean; hasShareIntegration: boolean; persistedDiscoverSession: DiscoverSession | undefined; -}): AppMenuConfig => { +}): DiscoverAppMenuConfig => { const intl = useI18n(); const dispatch = useInternalStateDispatch(); const currentDataView = useCurrentDataView(); @@ -115,8 +120,8 @@ export const useTopNavLinks = ({ const defaultMenu = topNavCustomization?.defaultMenu; - const appMenuItems: AppMenuItemType[] = useMemo(() => { - const items: AppMenuItemType[] = []; + const appMenuItems: DiscoverAppMenuItemType[] = useMemo(() => { + const items: DiscoverAppMenuItemType[] = []; if (!defaultMenu?.inspectItem?.disabled) { const inspectAppMenuItem = getInspectAppMenuItem({ onOpenInspector }); items.push(inspectAppMenuItem); @@ -368,7 +373,7 @@ export const useTopNavLinks = ({ transitionFromDataViewToESQL, ]); - return useMemo((): AppMenuConfig => { + return useMemo((): DiscoverAppMenuConfig => { const config = appMenuRegistry.getAppMenuConfig(); return { @@ -376,10 +381,16 @@ export const useTopNavLinks = ({ enhanceAppMenuItemWithRunAction({ appMenuItem: item, services }) ), primaryActionItem: config.primaryActionItem - ? enhanceAppMenuItemWithRunAction({ appMenuItem: config.primaryActionItem, services }) + ? (enhanceAppMenuItemWithRunAction({ + appMenuItem: config.primaryActionItem, + services, + }) as DiscoverAppMenuPrimaryActionItem) : undefined, secondaryActionItem: config.secondaryActionItem - ? enhanceAppMenuItemWithRunAction({ appMenuItem: config.secondaryActionItem, services }) + ? (enhanceAppMenuItemWithRunAction({ + appMenuItem: config.secondaryActionItem, + services, + }) as DiscoverAppMenuSecondaryActionItem) : undefined, }; }, [appMenuRegistry, services]); diff --git a/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.ts b/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.ts index 427ae5da35473..7177b7cdb63b4 100644 --- a/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.ts +++ b/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.ts @@ -42,38 +42,48 @@ export function useInspector({ dataDocuments$: stateContainer.dataState.data$.documents$, }); - const onOpenInspector = useCallback(() => { - // prevent overlapping - dispatch(setExpandedDoc({ expandedDoc: undefined })); + const onOpenInspector = useCallback( + (onClose?: () => void) => { + // prevent overlapping + dispatch(setExpandedDoc({ expandedDoc: undefined })); - const inspectorAdapters = stateContainer.dataState.inspectorAdapters; + const inspectorAdapters = stateContainer.dataState.inspectorAdapters; - const requestAdapters = inspectorAdapters.lensRequests - ? [inspectorAdapters.requests, inspectorAdapters.lensRequests] - : [inspectorAdapters.requests]; + const requestAdapters = inspectorAdapters.lensRequests + ? [inspectorAdapters.requests, inspectorAdapters.lensRequests] + : [inspectorAdapters.requests]; - const session = inspector.open( - { - requests: new AggregateRequestAdapter(requestAdapters), - contexts: getContextsAdapter({ - onOpenDocDetails: (record) => { - session?.close(); - dispatch(setExpandedDoc({ expandedDoc: record })); - }, - }), - }, - { title: persistedDiscoverSession?.title } - ); + const session = inspector.open( + { + requests: new AggregateRequestAdapter(requestAdapters), + contexts: getContextsAdapter({ + onOpenDocDetails: (record) => { + session?.close(); + dispatch(setExpandedDoc({ expandedDoc: record })); + }, + }), + }, + { title: persistedDiscoverSession?.title } + ); - setInspectorSession(session); - }, [ - dispatch, - setExpandedDoc, - stateContainer.dataState.inspectorAdapters, - inspector, - getContextsAdapter, - persistedDiscoverSession?.title, - ]); + setInspectorSession(session); + + // Call onClose when inspector closes + if (onClose) { + session?.onClose.then(() => { + onClose(); + }); + } + }, + [ + dispatch, + setExpandedDoc, + stateContainer.dataState.inspectorAdapters, + inspector, + getContextsAdapter, + persistedDiscoverSession?.title, + ] + ); useEffect(() => { return () => { diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx index 177575e0b8656..40d3645d1ebea 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx @@ -128,7 +128,7 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi iconType: 'logoElasticsearch', run: (runParams) => { alert('Example Custom action clicked'); - const onFinishAction = runParams?.context?.onFinishAction as () => void; + const onFinishAction = runParams?.context.onFinishAction; onFinishAction(); // This allows to return focus back to the app menu DOM node }, @@ -165,7 +165,7 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi run: (runParams) => { // This is an example of a custom action that opens a flyout or any other custom modal. // To do so, simply return a React element and call onFinishAction when you're done. - const onFinishAction = runParams?.context?.onFinishAction as () => void; + const onFinishAction = runParams?.context.onFinishAction; return (
Example custom action clicked
@@ -181,7 +181,7 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi order: 1, label: 'Custom action (from Data Source profile)', run: (runParams) => { - const onFinishAction = runParams?.context?.onFinishAction as () => void; + const onFinishAction = runParams?.context.onFinishAction; alert('Example Data source action under root submenu clicked'); onFinishAction(); }, diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx index 818a40dccc58c..aca4b06cb835e 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx @@ -70,7 +70,7 @@ export const createExampleRootProfileProvider = (): RootProfileProvider => ({ testId: 'example-custom-root-action11', run: (runParams) => { alert('Example Root Custom action 11 clicked'); - const onFinishAction = runParams?.context?.onFinishAction as () => void; + const onFinishAction = runParams?.context.onFinishAction; onFinishAction(); // This allows to return focus back to the app menu DOM node }, }, diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx index cc2332a8a25f6..25a02122f0235 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx @@ -59,7 +59,7 @@ const registerDatasetQualityLink = ( run: (params) => { const refresh = timefilter.getRefreshInterval(); const { from, to } = timefilter.getTime(); - const onFinishAction = params?.context?.onFinishAction as () => void; + const onFinishAction = params?.context.onFinishAction; dataQualityLocator.navigate({ filters: { @@ -98,7 +98,7 @@ const registerCustomThresholdRuleAction = ( }), run: (params) => { - const onFinishAction = params?.context?.onFinishAction as () => void; + const onFinishAction = params?.context.onFinishAction; const index = dataView?.toMinimalSpec(); const { filters, query } = data.query.getState(); @@ -161,7 +161,7 @@ const registerCreateSLOAction = ( iconType: 'visGauge', testId: 'discoverAppMenuCreateSlo', run: (params) => { - const onFinishAction = params?.context?.onFinishAction as () => void; + const onFinishAction = params?.context.onFinishAction; const index = dataView?.getIndexPattern(); const timestampField = dataView?.timeFieldName; const { filters, query: kqlQuery } = data.query.getState(); diff --git a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts index 3d81fc3abc3a3..b2d24cbf98169 100644 --- a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts +++ b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts @@ -61,8 +61,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; const expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed = async ( - menuButtonTestSubject: string + menuButtonTestSubject: string, + isInOverflowMenu: boolean = false ) => { + if (isInOverflowMenu) { + await focusAndPressButton('app-menu-overflow-button'); + } await focusAndPressButton(menuButtonTestSubject); await retry.try(async () => { expect(await hasFocus(menuButtonTestSubject)).to.be(false); @@ -71,7 +75,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await new Promise((resolve) => setTimeout(resolve, 500)); await browser.pressKeys(browser.keys.ESCAPE); await retry.try(async () => { - expect(await hasFocus(menuButtonTestSubject)).to.be(true); + expect( + await hasFocus(isInOverflowMenu ? 'app-menu-overflow-button' : menuButtonTestSubject) + ).to.be(true); }); }; @@ -106,7 +112,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed('shareTopNavButton')); it('should return focus to the inspect button when dismissing the inspector flyout', () => - expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed('openInspectorButton')); + expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed('openInspectorButton', true)); it('should return focus to the save button when dismissing the save modal', () => expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed('discoverSaveButton')); From 0c1051b7258cc5db458df831d2d26d2ff7debfe6 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Fri, 16 Jan 2026 21:59:36 +0100 Subject: [PATCH 33/76] Chores --- .../top_nav/app_menu_actions/get_alerts.tsx | 4 ++-- .../top_nav/app_menu_actions/get_inspect.tsx | 4 ++-- .../app_menu_actions/get_open_search.tsx | 3 +-- .../example_data_source_profile/profile.tsx | 9 +++------ .../example/example_root_profile/profile.tsx | 19 ++++++++++--------- .../accessors/get_app_menu.tsx | 9 +++------ 6 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx index 89fb302e6a423..b41cfc6e1d916 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx @@ -155,10 +155,10 @@ export const getAlertsAppMenuItem = ({ : i18n.translate('discover.alerts.missedTimeFieldToolTip', { defaultMessage: 'Data view does not have a time field.', }), - run: (params) => { + run: ({ context: { onFinishAction } }) => { return ( { - onOpenInspector(params.context.onFinishAction); + run: ({ context: { onFinishAction } }) => { + onOpenInspector(onFinishAction); }, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx index 4eca29edccad6..c3c8d5e795f8d 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_open_search.tsx @@ -26,8 +26,7 @@ export const getOpenSearchAppMenuItem = ({ }), iconType: 'folderOpen', testId: 'discoverOpenButton', - run: (params) => { - const onFinishAction = params?.context.onFinishAction; + run: ({ context: { onFinishAction } }) => { return ; }, }; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx index 40d3645d1ebea..99ace28ee988f 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx @@ -126,9 +126,8 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi label: 'Custom action', testId: 'example-custom-action', iconType: 'logoElasticsearch', - run: (runParams) => { + run: ({ context: { onFinishAction } }) => { alert('Example Custom action clicked'); - const onFinishAction = runParams?.context.onFinishAction; onFinishAction(); // This allows to return focus back to the app menu DOM node }, @@ -162,10 +161,9 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi label: 'Create SLO (Custom action)', iconType: 'visGauge', testId: 'example-custom-action-under-alerts', - run: (runParams) => { + run: ({ context: { onFinishAction } }) => { // This is an example of a custom action that opens a flyout or any other custom modal. // To do so, simply return a React element and call onFinishAction when you're done. - const onFinishAction = runParams?.context.onFinishAction; return (
Example custom action clicked
@@ -180,8 +178,7 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi id: 'example-custom-action5', order: 1, label: 'Custom action (from Data Source profile)', - run: (runParams) => { - const onFinishAction = runParams?.context.onFinishAction; + run: ({ context: { onFinishAction } }) => { alert('Example Data source action under root submenu clicked'); onFinishAction(); }, diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx index aca4b06cb835e..49774a5932111 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx @@ -68,9 +68,8 @@ export const createExampleRootProfileProvider = (): RootProfileProvider => ({ order: 1, label: 'Custom action 11 (from Root profile)', testId: 'example-custom-root-action11', - run: (runParams) => { + run: ({ context: { onFinishAction } }) => { alert('Example Root Custom action 11 clicked'); - const onFinishAction = runParams?.context.onFinishAction; onFinishAction(); // This allows to return focus back to the app menu DOM node }, }, @@ -79,15 +78,17 @@ export const createExampleRootProfileProvider = (): RootProfileProvider => ({ order: 2, label: 'Custom action 12 (from Root profile)', testId: 'example-custom-root-action12', - run: () => { + run: ({ context: { onFinishAction } }) => { // This is an example of a custom action that opens a flyout or any other custom modal. // To do so, simply return a React element and call onFinishAction when you're done. - // - //
Example custom action clicked
- //
; + return ( + +
Example custom action clicked
+
+ ); }, }, ], diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx index 25a02122f0235..a79b6ed314eb8 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/observability_root_profile/accessors/get_app_menu.tsx @@ -56,10 +56,9 @@ const registerDatasetQualityLink = ( order: 5, iconType: 'database', testId: 'discoverAppMenuDatasetQualityLink', - run: (params) => { + run: ({ context: { onFinishAction } }) => { const refresh = timefilter.getRefreshInterval(); const { from, to } = timefilter.getTime(); - const onFinishAction = params?.context.onFinishAction; dataQualityLocator.navigate({ filters: { @@ -97,8 +96,7 @@ const registerCustomThresholdRuleAction = ( defaultMessage: 'Create custom threshold rule', }), - run: (params) => { - const onFinishAction = params?.context.onFinishAction; + run: ({ context: { onFinishAction } }) => { const index = dataView?.toMinimalSpec(); const { filters, query } = data.query.getState(); @@ -160,8 +158,7 @@ const registerCreateSLOAction = ( }), iconType: 'visGauge', testId: 'discoverAppMenuCreateSlo', - run: (params) => { - const onFinishAction = params?.context.onFinishAction; + run: ({ context: { onFinishAction } }) => { const index = dataView?.getIndexPattern(); const timestampField = dataView?.timeFieldName; const { filters, query: kqlQuery } = data.query.getState(); From f4d502a4ed6b0e9f40fd0e978f1094196b79a537 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Sat, 17 Jan 2026 00:19:13 +0100 Subject: [PATCH 34/76] Test fixes --- .../components/app_menu/app_menu_registry.ts | 30 ++++++++++--------- .../top_nav/discover_topnav_menu.tsx | 15 +++++++++- .../extensions/_get_app_menu.ts | 4 +++ .../extensions/_get_app_menu.ts | 2 ++ 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts index 9ecac0ad5dbc2..bd58e6105d0e6 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts @@ -26,21 +26,16 @@ import type { */ export class AppMenuRegistry { static CUSTOM_ITEMS_LIMIT = 2; - private items: Map = new Map(); - private customItems: Map = new Map(); + private items: Map = new Map(); private primaryActionItem?: DiscoverAppMenuPrimaryActionItem; private secondaryActionItem?: DiscoverAppMenuSecondaryActionItem; - private getCustomItems(): DiscoverAppMenuItemType[] { - return Array.from(this.customItems.values()).slice(0, AppMenuRegistry.CUSTOM_ITEMS_LIMIT); - } - /** * Register a custom menu item. * @param item The menu item to register */ public registerCustomItem(item: DiscoverAppMenuItemType) { - this.customItems.set(item.id, item); + this.items.set(item.id, { ...item, isCustom: true }); } /** @@ -49,13 +44,13 @@ export class AppMenuRegistry { * @param popoverItem The popover item to register */ public registerCustomPopoverItem(parentId: string, popoverItem: DiscoverAppMenuPopoverItem) { - const parent = this.customItems.get(parentId); - this.customItems.set(parentId, { + const parent = this.items.get(parentId); + this.items.set(parentId, { ...parent, items: [...(parent?.items || []), popoverItem].sort( (a, b) => (a.order || 0) - (b.order || 0) ), - } as DiscoverAppMenuItemType); + } as DiscoverAppMenuItemType & { isCustom?: boolean }); } /** @@ -63,7 +58,7 @@ export class AppMenuRegistry { * @param item The menu item to register */ public registerItem(item: DiscoverAppMenuItemType) { - this.items.set(item.id, item); + this.items.set(item.id, { ...item, isCustom: false }); } /** @@ -110,10 +105,17 @@ export class AppMenuRegistry { * Items with registered popover items will have their items property populated. */ public getAppMenuConfig(): AppMenuConfig { + const allItems = Array.from(this.items.values()); + const regularItems = allItems.filter((item) => !item.isCustom); + const customItems = allItems + .filter((item) => item.isCustom) + .slice(0, AppMenuRegistry.CUSTOM_ITEMS_LIMIT); + + // Remove isCustom flag before returning + const cleanItems = [...regularItems, ...customItems].map(({ isCustom, ...item }) => item); + return { - items: Array.from(this.items.values()) - .concat(this.getCustomItems()) - .sort((a, b) => (a.order || 0) - (b.order || 0)) as AppMenuItemType[], + items: cleanItems.sort((a, b) => (a.order || 0) - (b.order || 0)) as AppMenuItemType[], primaryActionItem: this.primaryActionItem as AppMenuPrimaryActionItem | undefined, secondaryActionItem: this.secondaryActionItem as AppMenuSecondaryActionItem | undefined, }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx index 147ac13f45c83..2ced1a8f7e594 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx @@ -17,7 +17,11 @@ import React, { import { BehaviorSubject } from 'rxjs'; import useUnmount from 'react-use/lib/useUnmount'; import type { DiscoverAppMenuConfig } from '@kbn/discover-utils'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; +import { AppMenu } from '@kbn/core-chrome-app-menu'; import type { useDiscoverTopNav } from './use_discover_topnav'; +import { useDiscoverServices } from '../../../../hooks/use_discover_services'; +import { useDiscoverCustomization } from '../../../../customizations'; /** * We handle the top nav menu this way because we need to render it higher in the tree than @@ -54,10 +58,19 @@ export const DiscoverTopNavMenu = ({ topNavMenu, }: Pick, 'topNavMenu'>) => { const { topNavMenu$ } = useContext(discoverTopNavMenuContext); + const { chrome } = useDiscoverServices(); + const topNavCustomization = useDiscoverCustomization('top_nav'); useLayoutEffect(() => { topNavMenu$.next(topNavMenu); }, [topNavMenu, topNavMenu$]); - return null; + /** + * Render app menu for SingleTabView when customizations exist + */ + if (!topNavCustomization) { + return null; + } + + return ; }; diff --git a/src/platform/test/functional/apps/discover/context_awareness/extensions/_get_app_menu.ts b/src/platform/test/functional/apps/discover/context_awareness/extensions/_get_app_menu.ts index 74a529ea9f4c7..d7ddc1d8982b7 100644 --- a/src/platform/test/functional/apps/discover/context_awareness/extensions/_get_app_menu.ts +++ b/src/platform/test/functional/apps/discover/context_awareness/extensions/_get_app_menu.ts @@ -44,6 +44,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); await testSubjects.existOrFail('discoverNewButton'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('discoverAlertsButton'); await testSubjects.existOrFail('example-custom-root-submenu'); }); @@ -59,7 +60,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); await testSubjects.existOrFail('discoverNewButton'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('discoverAlertsButton'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('example-custom-root-submenu'); await testSubjects.existOrFail('example-custom-action'); @@ -70,6 +73,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('example-custom-root-action12-flyout'); await testSubjects.click('euiFlyoutCloseButton'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('discoverAlertsButton'); await testSubjects.existOrFail('example-custom-action-under-alerts'); }); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/context_awareness/extensions/_get_app_menu.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/context_awareness/extensions/_get_app_menu.ts index fb173d3640118..01cc522b56e6e 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/context_awareness/extensions/_get_app_menu.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/context_awareness/extensions/_get_app_menu.ts @@ -47,6 +47,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); await testSubjects.existOrFail('discoverNewButton'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('discoverAlertsButton'); }); @@ -63,6 +64,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); await testSubjects.existOrFail('discoverNewButton'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('discoverAlertsButton'); await testSubjects.existOrFail('example-custom-action'); From 1ff33917f9a81529c37fdd02553e926bc353ab6b Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:32:16 +0000 Subject: [PATCH 35/76] Changes from node scripts/lint_ts_projects --fix --- src/platform/plugins/shared/discover/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 99b61bf6c33c0..1c18c5e94fa50 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -124,6 +124,7 @@ "@kbn/controls-renderer", "@kbn/core-chrome-app-menu-components", "@kbn/shared-ux-error-boundary", + "@kbn/core-chrome-app-menu", ], "exclude": ["target/**/*"] } From 938d5aea82e7231a2e2027abdc9031d61c0ecf05 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:44:16 +0000 Subject: [PATCH 36/76] Changes from node scripts/regenerate_moon_projects.js --update --- src/platform/plugins/shared/discover/moon.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index 228bd757c0513..a429d8b096b2c 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -131,6 +131,7 @@ dependsOn: - '@kbn/controls-renderer' - '@kbn/core-chrome-app-menu-components' - '@kbn/shared-ux-error-boundary' + - '@kbn/core-chrome-app-menu' tags: - plugin - prod From e1c4bcf7f8f82ec86de63ab0959f46718bc27c0f Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Sat, 17 Jan 2026 02:10:36 +0100 Subject: [PATCH 37/76] o11y serverless fixes --- .../discover/context_awareness/_get_app_menu.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_app_menu.ts b/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_app_menu.ts index 22ab917710416..d81b3b5bd4299 100644 --- a/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_app_menu.ts +++ b/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_app_menu.ts @@ -49,6 +49,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should display a "Add data" link to navigate to the onboarding page', async () => { + const appMenuOverflowButton = await testSubjects.find('app-menu-overflow-button'); + await appMenuOverflowButton.click(); + const link = await testSubjects.find('discoverAppMenuDatasetQualityLink'); await link.click(); @@ -59,6 +62,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should display a "Create custom threshold rule" action under the Alerts menu to create an o11y alert', async () => { + const appMenuOverflowButton = await testSubjects.find('app-menu-overflow-button'); + await appMenuOverflowButton.click(); + const alertsButton = await testSubjects.find('discoverAlertsButton'); await alertsButton.click(); @@ -73,6 +79,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should display a "Create SLO" action under the Alerts menu to create an o11y alert', async () => { + const appMenuOverflowButton = await testSubjects.find('app-menu-overflow-button'); + await appMenuOverflowButton.click(); + const alertsButton = await testSubjects.find('discoverAlertsButton'); await alertsButton.click(); From df77f85601b7e832980a3eefc7da63f55d66659c Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Sat, 17 Jan 2026 12:51:39 +0100 Subject: [PATCH 38/76] Attempt at flaky test --- .../test_suites/discover/context_awareness/_get_doc_viewer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_doc_viewer.ts b/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_doc_viewer.ts index b8a41137dc183..063ae3702bd24 100644 --- a/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_doc_viewer.ts +++ b/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_doc_viewer.ts @@ -57,6 +57,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToActualUrl('discover', undefined, { ensureCurrentUrl: false, }); + await PageObjects.discover.waitUntilTabIsLoaded(); await dataViews.switchTo('my-example-logs'); await PageObjects.discover.waitUntilTabIsLoaded(); await dataGrid.clickRowToggle(); @@ -95,6 +96,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToActualUrl('discover', undefined, { ensureCurrentUrl: false, }); + await PageObjects.discover.waitUntilTabIsLoaded(); await dataViews.switchTo('my-example-metrics'); await PageObjects.discover.waitUntilTabIsLoaded(); await dataGrid.clickRowToggle(); From 02d270043484ac904fa406c8fcf04d9cd4d0282f Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Sat, 17 Jan 2026 14:22:27 +0100 Subject: [PATCH 39/76] Test fix --- .../src/components/app_menu/app_menu_registry.ts | 11 +++-------- .../src/components/tabbed_content/tabbed_content.tsx | 2 +- .../main/components/tabs_view/tabs_view.tsx | 8 ++++---- .../top_nav/app_menu_actions/get_new_search.tsx | 1 - .../app_menu_actions/run_app_menu_action.test.tsx | 5 ----- .../main/components/top_nav/discover_topnav_menu.tsx | 2 +- .../components/top_nav/use_top_nav_links.test.tsx | 1 - .../public/application/main/hooks/use_inspector.ts | 1 - .../discover/context_awareness/_get_doc_viewer.ts | 5 ++++- 9 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts index bd58e6105d0e6..c1ea2d14f1a9c 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts @@ -47,9 +47,7 @@ export class AppMenuRegistry { const parent = this.items.get(parentId); this.items.set(parentId, { ...parent, - items: [...(parent?.items || []), popoverItem].sort( - (a, b) => (a.order || 0) - (b.order || 0) - ), + items: [...(parent?.items || []), popoverItem], } as DiscoverAppMenuItemType & { isCustom?: boolean }); } @@ -94,9 +92,7 @@ export class AppMenuRegistry { const parent = this.items.get(parentId); this.items.set(parentId, { ...parent, - items: [...(parent?.items || []), popoverItem].sort( - (a, b) => (a.order || 0) - (b.order || 0) - ), + items: [...(parent?.items || []), popoverItem], } as DiscoverAppMenuItemType); } @@ -111,11 +107,10 @@ export class AppMenuRegistry { .filter((item) => item.isCustom) .slice(0, AppMenuRegistry.CUSTOM_ITEMS_LIMIT); - // Remove isCustom flag before returning const cleanItems = [...regularItems, ...customItems].map(({ isCustom, ...item }) => item); return { - items: cleanItems.sort((a, b) => (a.order || 0) - (b.order || 0)) as AppMenuItemType[], + items: cleanItems as AppMenuItemType[], primaryActionItem: this.primaryActionItem as AppMenuPrimaryActionItem | undefined, secondaryActionItem: this.secondaryActionItem as AppMenuSecondaryActionItem | undefined, }; diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx index 4e9fd5041d0de..298c68d5acfca 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx @@ -351,7 +351,7 @@ export const TabbedContent: React.FC = ({ `; const tabsBarComponentCss = css` - min-width: 0; /* without this, TabsBar would push out AppMenu */ + min-width: 0; /* Fixes an issue causing TabsBar to push appendRight to overflow as number of tabs grows */ `; const appendRightContainerCss = css` diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx index 5cc761b3bfd3c..f588a69aab4cb 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx @@ -84,18 +84,18 @@ export const TabsView = (props: SingleTabViewProps) => { return ( /** - * AppMenuComponent handles responsiveness on its own, however, there are some edge cases - * e.g opening push flyout, where this might not be good enough. - * Wrapping the whole tabs view in a resize observer ensures that the tabs view is always aware of the available width and can adjust the app menu accordingly. + * AppMenuComponent handles responsiveness on its own, however, there are some edge cases e.g opening push flyout + * where this might not be good enough. */ {(resizeRef) => (
{ onNewSearch(); - // New App Menu doesn't support running onClick when href is provided, so we need to handle navigation here onNavigate(); }, }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx index 4a14eae825d47..53d37f9714519 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx @@ -65,7 +65,6 @@ describe('run app menu actions', () => { }); expect(mockRun).toHaveBeenCalled(); - // No container should be added to body expect(document.body.querySelectorAll('div').length).toBe(0); }); @@ -100,15 +99,12 @@ describe('run app menu actions', () => { expect(capturedParams).toBeDefined(); expect(capturedParams?.context.onFinishAction).toBeDefined(); - // Container should be added const containers = document.body.querySelectorAll('div'); expect(containers.length).toBeGreaterThan(0); - // Call onFinishAction to cleanup const onFinishAction = capturedParams?.context?.onFinishAction; onFinishAction!(); - // Container should be removed (anchorElement still in DOM) expect(document.body.querySelectorAll('div').length).toBe(1); expect(focusSpy).toHaveBeenCalled(); }); @@ -154,7 +150,6 @@ describe('run app menu actions', () => { const triggerElement = document.createElement('div'); enhanced.run?.({ triggerElement, context: { onFinishAction: jest.fn() } }); - // Wait for async execution await new Promise((resolve) => setTimeout(resolve, 0)); expect(mockRun).toHaveBeenCalled(); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx index 2ced1a8f7e594..18e4fdf176bea 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx @@ -66,7 +66,7 @@ export const DiscoverTopNavMenu = ({ }, [topNavMenu, topNavMenu$]); /** - * Render app menu for SingleTabView when customizations exist + * Render app menu for SingleTabView when customizations exist. */ if (!topNavCustomization) { return null; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index 1b0a96e232fc6..4ae55e44de073 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -175,7 +175,6 @@ describe('useTopNavLinks', () => { expect(exportItem).toBeDefined(); expect(exportItem?.label).toBe('Export'); - // Export should have popover items expect((exportItem as DiscoverAppMenuItemType)?.items).toBeDefined(); expect((exportItem as DiscoverAppMenuItemType)?.items?.length).toBeGreaterThan(0); diff --git a/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.ts b/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.ts index 7177b7cdb63b4..3e388e0d2e977 100644 --- a/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.ts +++ b/src/platform/plugins/shared/discover/public/application/main/hooks/use_inspector.ts @@ -68,7 +68,6 @@ export function useInspector({ setInspectorSession(session); - // Call onClose when inspector closes if (onClose) { session?.onClose.then(() => { onClose(); diff --git a/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_doc_viewer.ts b/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_doc_viewer.ts index 063ae3702bd24..e447dd6072c0c 100644 --- a/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_doc_viewer.ts +++ b/x-pack/solutions/observability/test/serverless/functional/test_suites/discover/context_awareness/_get_doc_viewer.ts @@ -58,8 +58,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ensureCurrentUrl: false, }); await PageObjects.discover.waitUntilTabIsLoaded(); + // Closes tabPreview_contentPanel to prevent intercepting clicks to reduce flakiness + await browser.pressKeys(browser.keys.ESCAPE); await dataViews.switchTo('my-example-logs'); - await PageObjects.discover.waitUntilTabIsLoaded(); await dataGrid.clickRowToggle(); await testSubjects.existOrFail('docViewerTab-doc_view_table'); await testSubjects.existOrFail('docViewerTab-doc_view_logs_overview'); @@ -97,6 +98,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ensureCurrentUrl: false, }); await PageObjects.discover.waitUntilTabIsLoaded(); + // Closes tabPreview_contentPanel to prevent intercepting clicks to reduce flakiness + await browser.pressKeys(browser.keys.ESCAPE); await dataViews.switchTo('my-example-metrics'); await PageObjects.discover.waitUntilTabIsLoaded(); await dataGrid.clickRowToggle(); From 2d4fb0d5844466fc2b5be21626109e370be160d0 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 10:33:24 +0100 Subject: [PATCH 40/76] Increase browser size for _new_tab test suite --- .../test/functional/apps/discover/tabs/_new_tab.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/platform/test/functional/apps/discover/tabs/_new_tab.ts b/src/platform/test/functional/apps/discover/tabs/_new_tab.ts index 1f385101c3619..ed7d4640bf544 100644 --- a/src/platform/test/functional/apps/discover/tabs/_new_tab.ts +++ b/src/platform/test/functional/apps/discover/tabs/_new_tab.ts @@ -21,8 +21,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dataViews = getService('dataViews'); const esql = getService('esql'); const testSubjects = getService('testSubjects'); + const browser = getService('browser'); describe('opening a new tab', function () { + before(async () => { + await browser.setWindowSize(1920, 1080); + }); + it('should create a new tab in classic mode', async () => { // tab 0 - with the default data view @@ -104,7 +109,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await timePicker.setAbsoluteRange(fromTime, toTime); await discover.waitUntilTabIsLoaded(); - const tabCount = 4; + const tabCount = 7; for (let i = 0; i < tabCount; i++) { await testSubjects.click('unifiedTabs_tabsBar_newTabBtn'); From 363b1685216f32cd6d4a99951aa935e69d220b42 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 10:37:14 +0100 Subject: [PATCH 41/76] Fix sorting for popover items --- .../app-menu/core-chrome-app-menu-components/src/utils.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx index c243d8c37abc4..7eb16446a917d 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx @@ -239,7 +239,9 @@ export const getPopoverPanels = ({ panelIdToTestId[String(panelId)] = parentPopoverTestId; } - itemsToProcess.forEach((item) => { + const sortedItems = [...itemsToProcess].sort((a, b) => a.order - b.order); + + sortedItems.forEach((item) => { if (item.separator === 'above') { panelItems.push(createSeparatorItem(`separator-${item.id}`)); } From 5dfdb4887f0b564d03a693e96445aa39c0e9a9d2 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 17:16:23 +0100 Subject: [PATCH 42/76] Make ESQL button secondary button --- .../playwright/page_objects/discover_app.ts | 21 +-------------- .../components/top_nav/use_top_nav_links.tsx | 8 +++--- .../apps/discover/esql/_esql_view.ts | 12 ++++++--- .../group1/_discover_accessibility.ts | 5 ++-- .../functional/page_objects/discover_page.ts | 26 ------------------- .../page_objects/unified_search_page.ts | 3 +-- .../discover/search_source_alert.ts | 2 ++ .../test_suites/discover/esql/_esql_view.ts | 12 ++++++--- .../discover/search_source_alert.ts | 2 ++ 9 files changed, 29 insertions(+), 62 deletions(-) diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/discover_app.ts b/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/discover_app.ts index 556578f6e9bb1..715a68df04af9 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/discover_app.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/page_objects/discover_app.ts @@ -256,28 +256,9 @@ export class DiscoverApp { } async selectTextBaseLang() { - // First check if the button is directly visible - if (await this.page.testSubj.isVisible('select-text-based-language-btn')) { - await this.page.testSubj.isEnabled('select-text-based-language-btn'); + if (await this.page.testSubj.isEnabled('select-text-based-language-btn')) { await this.page.testSubj.click('select-text-based-language-btn'); await this.waitForDocTableRendered(); - return; - } - - // If not visible, try the overflow menu - if (await this.page.testSubj.isVisible('app-menu-overflow-button')) { - await this.page.testSubj.click('app-menu-overflow-button'); - - if (await this.page.testSubj.isVisible('select-text-based-language-btn')) { - await this.page.testSubj.isEnabled('select-text-based-language-btn'); - await this.page.testSubj.click('select-text-based-language-btn'); - await this.waitForDocTableRendered(); - } - - // Close the popover if open - if (await this.page.testSubj.isVisible('app-menu-popover')) { - await this.page.testSubj.click('app-menu-overflow-button'); - } } } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index bbb3d0c361839..ec1776a96a955 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -241,16 +241,17 @@ export const useTopNavLinks = ({ newAppMenuRegistry.registerItems(appMenuItems); if (services.uiSettings.get(ENABLE_ESQL)) { - newAppMenuRegistry.registerItem({ + newAppMenuRegistry.setSecondaryActionItem({ id: 'esql', label: isEsqlMode ? i18n.translate('discover.localMenu.switchToClassicTitle', { - defaultMessage: 'Switch to classic', + defaultMessage: 'Classic', }) : i18n.translate('discover.localMenu.tryESQLTitle', { - defaultMessage: 'Try ES|QL', + defaultMessage: 'ES|QL', }), iconType: 'editorCodeBlock', + color: 'text', tooltipContent: isEsqlMode ? i18n.translate('discover.localMenu.switchToClassicTooltipLabel', { defaultMessage: 'Switch to KQL or Lucene syntax.', @@ -277,7 +278,6 @@ export const useTopNavLinks = ({ } }, testId: isEsqlMode ? 'switch-to-dataviews' : 'select-text-based-language-btn', - order: 9, }); } diff --git a/src/platform/test/functional/apps/discover/esql/_esql_view.ts b/src/platform/test/functional/apps/discover/esql/_esql_view.ts index 6da15cfae0d2e..6b6ab61f3c3fc 100644 --- a/src/platform/test/functional/apps/discover/esql/_esql_view.ts +++ b/src/platform/test/functional/apps/discover/esql/_esql_view.ts @@ -87,7 +87,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await testSubjects.exists('dscViewModeDocumentButton')).to.be(true); expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true); expect(await testSubjects.exists('discoverQueryHits')).to.be(true); + await testSubjects.click('app-menu-overflow-button'); expect(await testSubjects.exists('discoverAlertsButton')).to.be(true); + await testSubjects.click('app-menu-overflow-button'); expect(await testSubjects.exists('shareTopNavButton')).to.be(true); expect(await testSubjects.exists('docTableExpandToggleColumn')).to.be(true); expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(true); @@ -109,7 +111,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // when Lens suggests a table, we render an ESQL based histogram expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true); expect(await testSubjects.exists('discoverQueryHits')).to.be(true); + await testSubjects.click('app-menu-overflow-button'); expect(await testSubjects.exists('discoverAlertsButton')).to.be(true); + await testSubjects.click('app-menu-overflow-button'); expect(await testSubjects.exists('shareTopNavButton')).to.be(true); // we don't sort for the Document view expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(false); @@ -299,7 +303,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show switch modal when switching to a data view', async () => { await discover.selectTextBaseLang(); await discover.waitUntilTabIsLoaded(); - await discover.selectDataViewMode(); + await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -312,7 +316,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); await discover.waitUntilTabIsLoaded(); - await discover.selectDataViewMode(); + await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -323,7 +327,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); await discover.saveSearch('esql_test'); - await discover.selectDataViewMode(); + await testSubjects.click('switch-to-dataviews'); await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); @@ -336,7 +340,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); await discover.waitUntilTabIsLoaded(); - await discover.selectDataViewMode(); + await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); diff --git a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts index b2d24cbf98169..ad69478819a2a 100644 --- a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts +++ b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts @@ -88,8 +88,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed('discoverAlertsButton')); it('should return focus to the alerts button when dismissing the create rule flyout', async () => { + await testSubjects.click('app-menu-overflow-button'); await focusAndPressButton('discoverAlertsButton'); - expect(await hasFocus('discoverAlertsButton')).to.be(false); + expect(await hasFocus('discoverAlertsButton')).to.be(true); await focusAndPressButton('discoverCreateAlertButton'); expect(await testSubjects.exists('addRuleFlyoutTitle')).to.be(true); await retry.try(async () => { @@ -104,7 +105,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ) ); } - expect(await hasFocus('discoverAlertsButton')).to.be(true); + expect(await hasFocus('app-menu-overflow-button')).to.be(true); }); }); diff --git a/src/platform/test/functional/page_objects/discover_page.ts b/src/platform/test/functional/page_objects/discover_page.ts index f69edfa10add8..9f22a16c28e45 100644 --- a/src/platform/test/functional/page_objects/discover_page.ts +++ b/src/platform/test/functional/page_objects/discover_page.ts @@ -665,32 +665,6 @@ export class DiscoverPageObject extends FtrService { } } - public async selectDataViewMode() { - // First check if the button is directly visible - if (await this.testSubjects.exists('switch-to-dataviews')) { - await this.testSubjects.click('switch-to-dataviews'); - await this.header.waitUntilLoadingHasFinished(); - await this.waitUntilSearchingHasFinished(); - return; - } - - // If not visible, try the overflow menu - if (await this.testSubjects.exists('app-menu-overflow-button')) { - await this.testSubjects.click('app-menu-overflow-button'); - - if (await this.testSubjects.exists('switch-to-dataviews')) { - await this.testSubjects.click('switch-to-dataviews'); - await this.header.waitUntilLoadingHasFinished(); - await this.waitUntilSearchingHasFinished(); - } - - // Close the popover if open - if (await this.testSubjects.exists('app-menu-popover')) { - await this.testSubjects.click('app-menu-overflow-button'); - } - } - } - public async removeHeaderColumn(name: string) { await this.dataGrid.clickRemoveColumn(name); } diff --git a/src/platform/test/functional/page_objects/unified_search_page.ts b/src/platform/test/functional/page_objects/unified_search_page.ts index ce7423174915f..4c9c9e5f4976b 100644 --- a/src/platform/test/functional/page_objects/unified_search_page.ts +++ b/src/platform/test/functional/page_objects/unified_search_page.ts @@ -13,7 +13,6 @@ export class UnifiedSearchPageObject extends FtrService { private readonly retry = this.ctx.getService('retry'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly find = this.ctx.getService('find'); - private readonly PageObjects = this.ctx.getPageObjects(['discover']); public async switchDataView(switchButtonSelector: string, dataViewTitle: string) { await this.testSubjects.click(switchButtonSelector); @@ -68,7 +67,7 @@ export class UnifiedSearchPageObject extends FtrService { } public async switchToDataViewMode() { - await this.PageObjects.discover.selectDataViewMode(); + await this.testSubjects.click('switch-to-dataviews'); await this.retry.waitFor('the modal to open', async () => { return await this.testSubjects.exists('discover-esql-to-dataview-modal'); }); diff --git a/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts b/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts index f1f4e2558c955..ddd1c586cf19b 100644 --- a/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts +++ b/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts @@ -198,8 +198,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; const openDiscoverAlertFlyout = async () => { + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('discoverAlertsButton'); await testSubjects.click('discoverCreateAlertButton'); + await testSubjects.click('app-menu-overflow-button'); }; const openManagementAlertFlyout = async () => { diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts index 74f36bb1ba78d..fd8b42a3ec7c5 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts @@ -76,7 +76,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('dscViewModeDocumentButton'); await testSubjects.existOrFail('unifiedHistogramChart'); await testSubjects.existOrFail('discoverQueryHits'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('discoverAlertsButton'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('shareTopNavButton'); await testSubjects.existOrFail('docTableExpandToggleColumn'); await testSubjects.existOrFail('dataGridColumnSortingButton'); @@ -99,7 +101,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // when Lens suggests a table, we render an ESQL based histogram await testSubjects.existOrFail('unifiedHistogramChart'); await testSubjects.existOrFail('discoverQueryHits'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('discoverAlertsButton'); + await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('shareTopNavButton'); await testSubjects.missingOrFail('dataGridColumnSortingButton'); await testSubjects.existOrFail('docTableExpandToggleColumn'); @@ -277,7 +281,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.selectTextBaseLang(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await PageObjects.discover.selectDataViewMode(); + await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -292,7 +296,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('querySubmitButton'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await PageObjects.discover.selectDataViewMode(); + await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -303,7 +307,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); await PageObjects.discover.saveSearch('esql_test'); - await PageObjects.discover.selectDataViewMode(); + await testSubjects.click('switch-to-dataviews'); await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); @@ -319,7 +323,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('querySubmitButton'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await PageObjects.discover.selectDataViewMode(); + await testSubjects.click('switch-to-dataviews'); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts b/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts index 52e59916823f0..a777c1c7bfb27 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts @@ -228,6 +228,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; const openDiscoverAlertFlyout = async () => { + await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('discoverAlertsButton'); // Different create rule buttons in serverless if (await testSubjects.exists('discoverCreateAlertButton')) { @@ -235,6 +236,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } else { await testSubjects.click('discoverAppMenuCustomThresholdRule'); } + await testSubjects.click('app-menu-overflow-button'); }; const openManagementAlertFlyout = async () => { From 3296c714f836dd1b884550681b83e7fd795d6d1c Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 17:49:59 +0100 Subject: [PATCH 43/76] Fix popover width within overflow button --- .../components/app_menu_overflow_button.tsx | 1 - .../src/components/app_menu_popover.tsx | 6 ++---- .../src/constants.ts | 1 + .../src/index.ts | 1 + .../src/utils.tsx | 21 +++++++++++++++---- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_overflow_button.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_overflow_button.tsx index 92cca52c75a7d..105d3370d953c 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_overflow_button.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_overflow_button.tsx @@ -80,7 +80,6 @@ export const AppMenuOverflowButton = ({ defaultMessage: 'More', })} isOpen={isPopoverOpen} - popoverWidth={200} primaryActionItem={primaryActionItem} secondaryActionItem={secondaryActionItem} onClose={onPopoverClose} diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.tsx index fb1abc1f258cb..1bd4bdf42e4d7 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.tsx @@ -54,10 +54,11 @@ export const AppMenuPopover = ({ items, primaryActionItem, secondaryActionItem, + rootPanelWidth: popoverWidth, onClose, onCloseOverflowButton, }), - [items, primaryActionItem, secondaryActionItem, onClose, onCloseOverflowButton] + [items, primaryActionItem, secondaryActionItem, popoverWidth, onClose, onCloseOverflowButton] ); if (panels.length === 0) { @@ -91,9 +92,6 @@ export const AppMenuPopover = ({ panelPaddingSize="none" hasArrow={false} anchorPosition={anchorPosition || 'upLeft'} - panelStyle={{ - width: popoverWidth, - }} panelProps={{ 'data-test-subj': activeTestId, }} diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/constants.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/constants.ts index 8bda71b4bff4e..2a6d2d647a4e6 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/constants.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/constants.ts @@ -10,3 +10,4 @@ export const APP_MENU_ITEM_LIMIT = 5; export const APP_MENU_NOTIFICATION_INDICATOR_TOP = 2; export const APP_MENU_NOTIFICATION_INDICATOR_LEFT = 25; +export const DEFAULT_POPOVER_WIDTH = 200; diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts index 181b3615044f4..d738b52f43576 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts @@ -29,6 +29,7 @@ export { APP_MENU_ITEM_LIMIT, APP_MENU_NOTIFICATION_INDICATOR_LEFT, APP_MENU_NOTIFICATION_INDICATOR_TOP, + DEFAULT_POPOVER_WIDTH, } from './constants'; export { diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx index 7eb16446a917d..f1a53b8583da3 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx @@ -23,7 +23,7 @@ import type { AppMenuPrimaryActionItem, AppMenuSecondaryActionItem, } from './types'; -import { APP_MENU_ITEM_LIMIT } from './constants'; +import { APP_MENU_ITEM_LIMIT, DEFAULT_POPOVER_WIDTH } from './constants'; /** * Calculate how many items can be displayed based on the presence of action buttons. @@ -212,6 +212,7 @@ export const getPopoverPanels = ({ primaryActionItem, secondaryActionItem, startPanelId = 0, + rootPanelWidth = DEFAULT_POPOVER_WIDTH, onClose, onCloseOverflowButton, }: { @@ -219,6 +220,7 @@ export const getPopoverPanels = ({ primaryActionItem?: AppMenuPrimaryActionItem; secondaryActionItem?: AppMenuSecondaryActionItem; startPanelId?: number; + rootPanelWidth?: number; onClose?: () => void; onCloseOverflowButton?: () => void; }): { panels: EuiContextMenuPanelDescriptor[]; panelIdToTestId: Record } => { @@ -231,7 +233,8 @@ export const getPopoverPanels = ({ itemsToProcess: AppMenuPopoverItem[], panelId: number, parentTitle?: string, - parentPopoverTestId?: string + parentPopoverTestId?: string, + parentPopoverWidth?: number ) => { const panelItems: EuiContextMenuPanelItemDescriptor[] = []; @@ -250,7 +253,16 @@ export const getPopoverPanels = ({ currentPanelId++; const childPanelId = currentPanelId; - processItems(item.items, childPanelId, item.label, item.popoverTestId); + // popoverWidth may exist on items that are AppMenuItemType (e.g., overflow items) + const itemPopoverWidth = + 'popoverWidth' in item ? (item as { popoverWidth?: number }).popoverWidth : undefined; + processItems( + item.items, + childPanelId, + item.label, + item.popoverTestId, + itemPopoverWidth ?? DEFAULT_POPOVER_WIDTH + ); panelItems.push( mapAppMenuItemToPanelItem(item, childPanelId, onClose, onCloseOverflowButton) ); @@ -266,11 +278,12 @@ export const getPopoverPanels = ({ panels.push({ id: panelId, ...(parentTitle && { title: upperFirst(parentTitle) }), + ...(parentPopoverWidth && { width: parentPopoverWidth }), items: panelItems, }); }; - processItems(items, startPanelId); + processItems(items, startPanelId, undefined, undefined, rootPanelWidth); /** * Action items are only added to the main panel and only in lower breakpoints (below "m"). From 54044605d6041666100955c4d9532a2be49b1433 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 17:58:36 +0100 Subject: [PATCH 44/76] Make process items accept object --- .../src/components/app_menu_popover.test.tsx | 6 +-- .../src/utils.tsx | 40 ++++++++++++------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.test.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.test.tsx index 288c0e8fe51f8..edebd67feecb2 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.test.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover.test.tsx @@ -92,14 +92,14 @@ describe('AppMenuPopover', () => { expect(anchorButton.closest('.euiToolTipAnchor')).not.toBeInTheDocument(); }); - it('should apply popoverWidth to the panel style', async () => { + it('should apply popoverWidth to the context menu', async () => { const { baseElement } = render( ); await waitFor(() => { - const popoverPanel = baseElement.querySelector('.euiPanel'); - expect(popoverPanel).toHaveStyle({ width: '300px' }); + const contextMenu = baseElement.querySelector('.euiContextMenu'); + expect(contextMenu).toHaveStyle({ width: '300px' }); }); }); }); diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx index f1a53b8583da3..c829ebe611510 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx @@ -229,13 +229,19 @@ export const getPopoverPanels = ({ const hasActionItems = Boolean(primaryActionItem || secondaryActionItem); let currentPanelId = startPanelId; - const processItems = ( - itemsToProcess: AppMenuPopoverItem[], - panelId: number, - parentTitle?: string, - parentPopoverTestId?: string, - parentPopoverWidth?: number - ) => { + const processItems = ({ + itemsToProcess, + panelId, + parentTitle, + parentPopoverTestId, + parentPopoverWidth, + }: { + itemsToProcess: AppMenuPopoverItem[]; + panelId: number; + parentTitle?: string; + parentPopoverTestId?: string; + parentPopoverWidth?: number; + }) => { const panelItems: EuiContextMenuPanelItemDescriptor[] = []; if (parentPopoverTestId) { @@ -256,13 +262,13 @@ export const getPopoverPanels = ({ // popoverWidth may exist on items that are AppMenuItemType (e.g., overflow items) const itemPopoverWidth = 'popoverWidth' in item ? (item as { popoverWidth?: number }).popoverWidth : undefined; - processItems( - item.items, - childPanelId, - item.label, - item.popoverTestId, - itemPopoverWidth ?? DEFAULT_POPOVER_WIDTH - ); + processItems({ + itemsToProcess: item.items, + panelId: childPanelId, + parentTitle: item.label, + parentPopoverTestId: item.popoverTestId, + parentPopoverWidth: itemPopoverWidth ?? DEFAULT_POPOVER_WIDTH, + }); panelItems.push( mapAppMenuItemToPanelItem(item, childPanelId, onClose, onCloseOverflowButton) ); @@ -283,7 +289,11 @@ export const getPopoverPanels = ({ }); }; - processItems(items, startPanelId, undefined, undefined, rootPanelWidth); + processItems({ + itemsToProcess: items, + panelId: startPanelId, + parentPopoverWidth: rootPanelWidth, + }); /** * Action items are only added to the main panel and only in lower breakpoints (below "m"). From 9008415967b00ff61b1b671de85624fd08dbbc7f Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 18:12:22 +0100 Subject: [PATCH 45/76] Fix test --- .../main/components/top_nav/use_top_nav_links.test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index 4ae55e44de073..3fc03646d66fb 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -105,11 +105,6 @@ describe('useTopNavLinks', () => { }); expect(appMenuConfig.items).toBeDefined(); - - // Check for ESQL switch item - const esqlItem = appMenuConfig.items?.find((item) => item.id === 'esql'); - expect(esqlItem).toBeDefined(); - expect(esqlItem?.label).toBe('Switch to classic'); }); }); From 179226865b0ebeb078b72c61efa7848a73d0bfdb Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 18:25:31 +0100 Subject: [PATCH 46/76] Move switch to classic button to tab menu --- .../packages/shared/kbn-unified-tabs/index.ts | 8 ++- .../tabbed_content/tabbed_content.tsx | 14 ++++- .../src/utils/get_tab_menu_items.tsx | 12 ++++ .../main/components/tabs_view/tabs_view.tsx | 62 ++++++++++++++++++- .../components/top_nav/use_discover_topnav.ts | 6 -- .../top_nav/use_top_nav_links.test.tsx | 2 - .../components/top_nav/use_top_nav_links.tsx | 50 ++++----------- 7 files changed, 104 insertions(+), 50 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-tabs/index.ts b/src/platform/packages/shared/kbn-unified-tabs/index.ts index c984acb592ef4..f78261bb7ceb7 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/index.ts +++ b/src/platform/packages/shared/kbn-unified-tabs/index.ts @@ -7,7 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export type { TabItem, TabPreviewData, TabsEBTEvent } from './src/types'; +export type { + TabItem, + TabPreviewData, + TabsEBTEvent, + TabMenuItem, + TabMenuItemWithClick, +} from './src/types'; export { TabStatus, TabsEventName } from './src/types'; export { TabsEventDataKeys } from './src/event_data_keys'; export { diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx index 298c68d5acfca..eb8f819d6e411 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx @@ -31,6 +31,7 @@ import type { TabPreviewData, TabsEBTEvent, RecentlyClosedTabItem, + TabMenuItem, } from '../../types'; import { TabsEventName } from '../../types'; import { getNextTabNumber } from '../../utils/get_next_tab_number'; @@ -62,6 +63,8 @@ export interface TabbedContentProps onEBTEvent: (event: TabsEBTEvent) => void; tabContentIdOverride?: string; appendRight?: React.ReactNode; + /** Optional function to provide additional menu items for tabs */ + getAdditionalTabMenuItems?: (item: TabItem) => TabMenuItem[]; } export interface TabbedContentState { @@ -104,6 +107,7 @@ export const TabbedContent: React.FC = ({ disableDragAndDrop = false, disableTabsBarMenu = false, appendRight, + getAdditionalTabMenuItems, }) => { const { euiTheme } = useEuiTheme(); const tabsBarApi = useRef(null); @@ -343,8 +347,16 @@ export const TabbedContent: React.FC = ({ onDuplicate, onCloseOtherTabs, onCloseTabsToTheRight, + getAdditionalTabMenuItems, }); - }, [state, maxItemsCount, onDuplicate, onCloseOtherTabs, onCloseTabsToTheRight]); + }, [ + state, + maxItemsCount, + onDuplicate, + onCloseOtherTabs, + onCloseTabsToTheRight, + getAdditionalTabMenuItems, + ]); const tabsBarContainerCss = css` background-color: ${euiTheme.colors.lightestShade}; diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx index 730b74c9ec497..1f7fe705c7a6d 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx @@ -39,6 +39,8 @@ export interface GetTabMenuItemsFnProps { onDuplicate: (item: TabItem) => void; onCloseOtherTabs: (item: TabItem) => void; onCloseTabsToTheRight: (item: TabItem) => void; + /** Optional function to provide additional menu items for tabs */ + getAdditionalTabMenuItems?: (item: TabItem) => TabMenuItem[]; } export const getTabMenuItemsFn = ({ @@ -47,6 +49,7 @@ export const getTabMenuItemsFn = ({ onDuplicate, onCloseOtherTabs, onCloseTabsToTheRight, + getAdditionalTabMenuItems, }: GetTabMenuItemsFnProps): GetTabMenuItems => { return (item) => { const closeOtherTabsItem = hasSingleTab(tabsState) @@ -109,6 +112,15 @@ export const getTabMenuItemsFn = ({ } } + // Add any additional menu items provided by the consumer + const additionalItems = getAdditionalTabMenuItems?.(item); + if (additionalItems && additionalItems.length > 0) { + if (items.length > 0) { + items.push(DividerMenuItem); + } + items.push(...additionalItems); + } + return items; }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx index f588a69aab4cb..f71092264ff41 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx @@ -7,12 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useContext, useState } from 'react'; +import React, { useCallback, useContext, useMemo, useState } from 'react'; import { EuiResizeObserver, type EuiResizeObserverProps } from '@elastic/eui'; -import { UnifiedTabs, type UnifiedTabsProps } from '@kbn/unified-tabs'; +import { UnifiedTabs, type UnifiedTabsProps, type TabMenuItem } from '@kbn/unified-tabs'; import useObservable from 'react-use/lib/useObservable'; import { AppMenuComponent, type AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { ENABLE_ESQL } from '@kbn/esql-utils'; import { SingleTabView, type SingleTabViewProps } from '../single_tab_view'; import { discoverTopNavMenuContext } from '../top_nav/discover_topnav_menu'; import { @@ -24,9 +27,12 @@ import { useInternalStateDispatch, useInternalStateSelector, useCurrentTabRuntimeState, + useCurrentTabAction, } from '../../state_management/redux'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { usePreviewData } from './use_preview_data'; +import { useIsEsqlMode } from '../../hooks/use_is_esql_mode'; +import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; const MAX_TABS_COUNT = 25; const APP_MENU_COLLAPSE_THRESHOLD = 800; @@ -41,6 +47,22 @@ export const TabsView = (props: SingleTabViewProps) => { const { getPreviewData } = usePreviewData(props.runtimeStateManager); const hideTabsBar = useInternalStateSelector(selectIsTabsBarHidden); const unsavedTabIds = useInternalStateSelector((state) => state.tabs.unsavedIds); + const isEsqlMode = useIsEsqlMode(); + const currentDataView = useCurrentTabRuntimeState( + props.runtimeStateManager, + (tab) => tab.currentDataView$ + ); + + const transitionFromESQLToDataView = useCurrentTabAction( + internalStateActions.transitionFromESQLToDataView + ); + + // Determine if we should show the ES|QL to Data View transition modal + const persistedDiscoverSession = useInternalStateSelector( + (state) => state.persistedDiscoverSession + ); + const shouldShowESQLToDataViewTransitionModal = + !persistedDiscoverSession || unsavedTabIds.includes(currentTabId); const scopedEbtManager = useCurrentTabRuntimeState( props.runtimeStateManager, @@ -79,6 +101,41 @@ export const TabsView = (props: SingleTabViewProps) => { [currentTabId, props] ); + // Provide "Switch to Classic" menu item for tabs when in ES|QL mode + const getAdditionalTabMenuItems: UnifiedTabsProps['getAdditionalTabMenuItems'] = useMemo(() => { + if (!isEsqlMode || !services.uiSettings.get(ENABLE_ESQL)) { + return undefined; + } + + return (): TabMenuItem[] => [ + { + 'data-test-subj': 'unifiedTabs_tabMenuItem_switchToClassic', + name: 'switchToClassic', + label: i18n.translate('discover.tabMenu.switchToClassicTitle', { + defaultMessage: 'Switch to classic', + }), + onClick: () => { + services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:back_to_classic_clicked`); + if ( + shouldShowESQLToDataViewTransitionModal && + !services.storage.get(ESQL_TRANSITION_MODAL_KEY) + ) { + dispatch(internalStateActions.setIsESQLToDataViewTransitionModalVisible(true)); + } else { + dispatch(transitionFromESQLToDataView({ dataViewId: currentDataView?.id ?? '' })); + } + }, + }, + ]; + }, [ + isEsqlMode, + services, + shouldShowESQLToDataViewTransitionModal, + dispatch, + transitionFromESQLToDataView, + currentDataView, + ]); + const { topNavMenu$ } = useContext(discoverTopNavMenuContext); const topNavMenuItems = useObservable(topNavMenu$, topNavMenu$.getValue()); @@ -111,6 +168,7 @@ export const TabsView = (props: SingleTabViewProps) => { onChanged={onChanged} onEBTEvent={onEvent} onClearRecentlyClosed={onClearRecentlyClosed} + getAdditionalTabMenuItems={getAdditionalTabMenuItems} appendRight={ state.tabs.unsavedIds); - const currentTabId = useCurrentTabSelector((tab) => tab.id); - const shouldShowESQLToDataViewTransitionModal = - !persistedDiscoverSession || unsavedTabIds.includes(currentTabId); const dataView = useCurrentDataView(); const adHocDataViews = useAdHocDataViews(); const isEsqlMode = useIsEsqlMode(); @@ -76,7 +71,6 @@ export const useDiscoverTopNav = ({ isEsqlMode, adHocDataViews, topNavCustomization, - shouldShowESQLToDataViewTransitionModal, hasShareIntegration, persistedDiscoverSession, }); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index 3fc03646d66fb..70703db034197 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -71,7 +71,6 @@ describe('useTopNavLinks', () => { isEsqlMode: false, adHocDataViews: [], topNavCustomization: undefined, - shouldShowESQLToDataViewTransitionModal: false, hasShareIntegration, persistedDiscoverSession: undefined, ...hookAttrs, @@ -157,7 +156,6 @@ describe('useTopNavLinks', () => { isEsqlMode: false, adHocDataViews: [], topNavCustomization: undefined, - shouldShowESQLToDataViewTransitionModal: false, hasShareIntegration: true, persistedDiscoverSession: undefined, }), diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index ec1776a96a955..b2b5052a57c43 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -27,7 +27,6 @@ import useObservable from 'react-use/lib/useObservable'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import { useI18n } from '@kbn/i18n-react'; import { createDataViewDataSource } from '../../../../../common/data_sources'; -import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; import type { DiscoverServices } from '../../../../build_services'; import type { DiscoverStateContainer } from '../../state_management/discover_state'; import type { AppMenuDiscoverParams } from './app_menu_actions'; @@ -66,7 +65,6 @@ export const useTopNavLinks = ({ isEsqlMode, adHocDataViews, topNavCustomization, - shouldShowESQLToDataViewTransitionModal, hasShareIntegration, persistedDiscoverSession, }: { @@ -78,7 +76,6 @@ export const useTopNavLinks = ({ isEsqlMode: boolean; adHocDataViews: DataView[]; topNavCustomization: TopNavCustomization | undefined; - shouldShowESQLToDataViewTransitionModal: boolean; hasShareIntegration: boolean; persistedDiscoverSession: DiscoverSession | undefined; }): DiscoverAppMenuConfig => { @@ -227,9 +224,6 @@ export const useTopNavLinks = ({ intl, ]); - const transitionFromESQLToDataView = useCurrentTabAction( - internalStateActions.transitionFromESQLToDataView - ); const transitionFromDataViewToESQL = useCurrentTabAction( internalStateActions.transitionFromDataViewToESQL ); @@ -240,44 +234,26 @@ export const useTopNavLinks = ({ newAppMenuRegistry.registerItems(appMenuItems); - if (services.uiSettings.get(ENABLE_ESQL)) { + // Only show the ES|QL button in classic mode (not in ES|QL mode) + // The "Switch to Classic" option is now in the tab menu when in ES|QL mode + if (services.uiSettings.get(ENABLE_ESQL) && !isEsqlMode) { newAppMenuRegistry.setSecondaryActionItem({ id: 'esql', - label: isEsqlMode - ? i18n.translate('discover.localMenu.switchToClassicTitle', { - defaultMessage: 'Classic', - }) - : i18n.translate('discover.localMenu.tryESQLTitle', { - defaultMessage: 'ES|QL', - }), + label: i18n.translate('discover.localMenu.tryESQLTitle', { + defaultMessage: 'ES|QL', + }), iconType: 'editorCodeBlock', color: 'text', - tooltipContent: isEsqlMode - ? i18n.translate('discover.localMenu.switchToClassicTooltipLabel', { - defaultMessage: 'Switch to KQL or Lucene syntax.', - }) - : i18n.translate('discover.localMenu.esqlTooltipLabel', { - defaultMessage: `ES|QL is Elastic's powerful new piped query language.`, - }), + tooltipContent: i18n.translate('discover.localMenu.esqlTooltipLabel', { + defaultMessage: `ES|QL is Elastic's powerful new piped query language.`, + }), run: () => { if (dataView) { - if (isEsqlMode) { - services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:back_to_classic_clicked`); - if ( - shouldShowESQLToDataViewTransitionModal && - !services.storage.get(ESQL_TRANSITION_MODAL_KEY) - ) { - dispatch(internalStateActions.setIsESQLToDataViewTransitionModalVisible(true)); - } else { - dispatch(transitionFromESQLToDataView({ dataViewId: dataView.id ?? '' })); - } - } else { - dispatch(transitionFromDataViewToESQL({ dataView })); - services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:try_btn_clicked`); - } + dispatch(transitionFromDataViewToESQL({ dataView })); + services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:try_btn_clicked`); } }, - testId: isEsqlMode ? 'switch-to-dataviews' : 'select-text-based-language-btn', + testId: 'select-text-based-language-btn', }); } @@ -364,12 +340,10 @@ export const useTopNavLinks = ({ services, isEsqlMode, dataView, - shouldShowESQLToDataViewTransitionModal, dispatch, state, defaultMenu?.saveItem?.disabled, hasUnsavedChanges, - transitionFromESQLToDataView, transitionFromDataViewToESQL, ]); From 351fff92b473789eed74b100a2faf0f43155cbe2 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 20:20:54 +0100 Subject: [PATCH 47/76] Fix tests --- .../functional/apps/discover/esql/_esql_view.ts | 8 ++++---- .../functional/page_objects/discover_page.ts | 17 +++++++++++++++++ .../page_objects/unified_search_page.ts | 3 ++- .../test_suites/discover/esql/_esql_view.ts | 8 ++++---- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/platform/test/functional/apps/discover/esql/_esql_view.ts b/src/platform/test/functional/apps/discover/esql/_esql_view.ts index 6b6ab61f3c3fc..d2bf5b8ece635 100644 --- a/src/platform/test/functional/apps/discover/esql/_esql_view.ts +++ b/src/platform/test/functional/apps/discover/esql/_esql_view.ts @@ -303,7 +303,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show switch modal when switching to a data view', async () => { await discover.selectTextBaseLang(); await discover.waitUntilTabIsLoaded(); - await testSubjects.click('switch-to-dataviews'); + await discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -316,7 +316,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); await discover.waitUntilTabIsLoaded(); - await testSubjects.click('switch-to-dataviews'); + await discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -327,7 +327,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); await discover.saveSearch('esql_test'); - await testSubjects.click('switch-to-dataviews'); + await discover.selectDataViewMode(); await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); @@ -340,7 +340,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await monacoEditor.setCodeEditorValue(testQuery); await testSubjects.click('querySubmitButton'); await discover.waitUntilTabIsLoaded(); - await testSubjects.click('switch-to-dataviews'); + await discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); diff --git a/src/platform/test/functional/page_objects/discover_page.ts b/src/platform/test/functional/page_objects/discover_page.ts index 9f22a16c28e45..117555234c6b5 100644 --- a/src/platform/test/functional/page_objects/discover_page.ts +++ b/src/platform/test/functional/page_objects/discover_page.ts @@ -665,6 +665,23 @@ export class DiscoverPageObject extends FtrService { } } + public async selectDataViewMode() { + // Get tab elements and open the menu for the first tab + const tabElements = await this.find.allByCssSelector('[data-test-subj^="unifiedTabs_tab_"]'); + if (tabElements.length > 0) { + const menuButton = await tabElements[0].findByCssSelector( + '[data-test-subj^="unifiedTabs_tabMenuBtn_"]' + ); + await menuButton.click(); + await this.retry.waitFor('tab menu to open', async () => { + return await this.testSubjects.exists('unifiedTabs_tabMenuItem_switchToClassic'); + }); + await this.testSubjects.click('unifiedTabs_tabMenuItem_switchToClassic'); + await this.header.waitUntilLoadingHasFinished(); + await this.waitUntilSearchingHasFinished(); + } + } + public async removeHeaderColumn(name: string) { await this.dataGrid.clickRemoveColumn(name); } diff --git a/src/platform/test/functional/page_objects/unified_search_page.ts b/src/platform/test/functional/page_objects/unified_search_page.ts index 4c9c9e5f4976b..ce7423174915f 100644 --- a/src/platform/test/functional/page_objects/unified_search_page.ts +++ b/src/platform/test/functional/page_objects/unified_search_page.ts @@ -13,6 +13,7 @@ export class UnifiedSearchPageObject extends FtrService { private readonly retry = this.ctx.getService('retry'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly find = this.ctx.getService('find'); + private readonly PageObjects = this.ctx.getPageObjects(['discover']); public async switchDataView(switchButtonSelector: string, dataViewTitle: string) { await this.testSubjects.click(switchButtonSelector); @@ -67,7 +68,7 @@ export class UnifiedSearchPageObject extends FtrService { } public async switchToDataViewMode() { - await this.testSubjects.click('switch-to-dataviews'); + await this.PageObjects.discover.selectDataViewMode(); await this.retry.waitFor('the modal to open', async () => { return await this.testSubjects.exists('discover-esql-to-dataview-modal'); }); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts index fd8b42a3ec7c5..72e06dcec0a39 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/esql/_esql_view.ts @@ -281,7 +281,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.selectTextBaseLang(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('switch-to-dataviews'); + await PageObjects.discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -296,7 +296,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('querySubmitButton'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('switch-to-dataviews'); + await PageObjects.discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); @@ -307,7 +307,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); await PageObjects.discover.saveSearch('esql_test'); - await testSubjects.click('switch-to-dataviews'); + await PageObjects.discover.selectDataViewMode(); await testSubjects.missingOrFail('discover-esql-to-dataview-modal'); }); @@ -323,7 +323,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('querySubmitButton'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('switch-to-dataviews'); + await PageObjects.discover.selectDataViewMode(); await retry.try(async () => { await testSubjects.existOrFail('discover-esql-to-dataview-modal'); }); From 658b74741405a11a7407f640302108be28f5feb8 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 20:26:46 +0100 Subject: [PATCH 48/76] Change button color --- .../application/main/components/top_nav/use_top_nav_links.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index b2b5052a57c43..797f4a57d31d4 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -243,7 +243,7 @@ export const useTopNavLinks = ({ defaultMessage: 'ES|QL', }), iconType: 'editorCodeBlock', - color: 'text', + color: 'success', tooltipContent: i18n.translate('discover.localMenu.esqlTooltipLabel', { defaultMessage: `ES|QL is Elastic's powerful new piped query language.`, }), From 96eaa507161a5e9b8561370a1190da1da45c0212 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 20:48:51 +0100 Subject: [PATCH 49/76] Remove dead translation --- .../public/application/main/components/tabs_view/tabs_view.tsx | 2 +- .../plugins/private/translations/translations/de-DE.json | 1 - .../plugins/private/translations/translations/fr-FR.json | 1 - .../plugins/private/translations/translations/ja-JP.json | 1 - .../plugins/private/translations/translations/zh-CN.json | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx index f71092264ff41..9632e5144b04b 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx @@ -111,7 +111,7 @@ export const TabsView = (props: SingleTabViewProps) => { { 'data-test-subj': 'unifiedTabs_tabMenuItem_switchToClassic', name: 'switchToClassic', - label: i18n.translate('discover.tabMenu.switchToClassicTitle', { + label: i18n.translate('discover.localMenu.switchToClassicTitle', { defaultMessage: 'Switch to classic', }), onClick: () => { diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index adcaa97507335..5cc31416d49b5 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -2525,7 +2525,6 @@ "discover.localMenu.saveTitle": "Speichern", "discover.localMenu.shareTitle": "Teilen", "discover.localMenu.switchToClassicTitle": "Zu Classic wechseln", - "discover.localMenu.switchToClassicTooltipLabel": "Wechseln Sie zur KQL- oder Lucene-Syntax.", "discover.localMenu.tryESQLTitle": "ES|QL ausprobieren", "discover.logLevelLabels.alert": "Alarm", "discover.logLevelLabels.critical": "Kritisch", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 2355b8813de32..d8f903454c3ed 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -2544,7 +2544,6 @@ "discover.localMenu.saveTitle": "Enregistrer", "discover.localMenu.shareTitle": "Partager", "discover.localMenu.switchToClassicTitle": "Basculer vers le classique", - "discover.localMenu.switchToClassicTooltipLabel": "Passez à la syntaxe KQL ou Lucene.", "discover.localMenu.tryESQLTitle": "Essayer ES|QL", "discover.logLevelLabels.alert": "Alerte", "discover.logLevelLabels.critical": "Critique", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 2528d9056373a..3885391c7e11e 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -2544,7 +2544,6 @@ "discover.localMenu.saveTitle": "保存", "discover.localMenu.shareTitle": "共有", "discover.localMenu.switchToClassicTitle": "クラシックに切り替える", - "discover.localMenu.switchToClassicTooltipLabel": "KQLまたはLucene構文に切り替えます。", "discover.localMenu.tryESQLTitle": "ES|QLを試す", "discover.logLevelLabels.alert": "アラート", "discover.logLevelLabels.critical": "重大", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 1d440d3d288ea..373da15df2a5a 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -2537,7 +2537,6 @@ "discover.localMenu.saveTitle": "保存", "discover.localMenu.shareTitle": "共享", "discover.localMenu.switchToClassicTitle": "切换到经典模式", - "discover.localMenu.switchToClassicTooltipLabel": "切换到 KQL 或 Lucene 语法。", "discover.localMenu.tryESQLTitle": "尝试 ES|QL", "discover.logLevelLabels.alert": "告警", "discover.logLevelLabels.critical": "紧急", From ac2c286f97c341d9c324b228a67fe472ec0acf63 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 20 Jan 2026 22:37:14 +0100 Subject: [PATCH 50/76] Fix tests --- .../app_menu/app_menu_registry.test.ts | 132 +----------------- .../group1/_discover_accessibility.ts | 14 +- .../apps/discover/group3/rule_creation.ts | 2 + .../discover/search_source_alert.ts | 1 - .../discover/search_source_alert.ts | 1 - 5 files changed, 15 insertions(+), 135 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts index 6fd98481ce48f..2c89527fc6d1d 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.test.ts @@ -178,85 +178,6 @@ describe('AppMenuRegistry', () => { expect(parent?.items).toHaveLength(1); expect(parent?.items?.[0]).toEqual(popoverItem); }); - - it('should sort popover items by order property', () => { - const parentItem: AppMenuItemType = { - id: 'parent', - order: 1, - label: 'Parent', - iconType: 'alert', - items: [], - }; - - const popoverItem1: AppMenuPopoverItem = { - id: 'child-1', - label: 'Child 1', - order: 3, - run: jest.fn(), - }; - - const popoverItem2: AppMenuPopoverItem = { - id: 'child-2', - label: 'Child 2', - order: 1, - run: jest.fn(), - }; - - const popoverItem3: AppMenuPopoverItem = { - id: 'child-3', - label: 'Child 3', - order: 2, - run: jest.fn(), - }; - - registry.registerItem(parentItem); - registry.registerPopoverItem('parent', popoverItem1); - registry.registerPopoverItem('parent', popoverItem2); - registry.registerPopoverItem('parent', popoverItem3); - - const config = registry.getAppMenuConfig(); - const parent = config.items?.find((item) => item.id === 'parent'); - - expect(parent?.items).toHaveLength(3); - expect(parent?.items?.[0].id).toBe('child-2'); - expect(parent?.items?.[1].id).toBe('child-3'); - expect(parent?.items?.[2].id).toBe('child-1'); - }); - - it('should handle popover items without order property', () => { - const parentItem: AppMenuItemType = { - id: 'parent', - order: 1, - label: 'Parent', - iconType: 'alert', - items: [], - }; - - const popoverItem1: AppMenuPopoverItem = { - id: 'child-1', - order: 0, - label: 'Child 1', - run: jest.fn(), - }; - - const popoverItem2: AppMenuPopoverItem = { - id: 'child-2', - label: 'Child 2', - order: 1, - run: jest.fn(), - }; - - registry.registerItem(parentItem); - registry.registerPopoverItem('parent', popoverItem1); - registry.registerPopoverItem('parent', popoverItem2); - - const config = registry.getAppMenuConfig(); - const parent = config.items?.find((item) => item.id === 'parent'); - - expect(parent?.items).toHaveLength(2); - expect(parent?.items?.[0].id).toBe('child-1'); - expect(parent?.items?.[1].id).toBe('child-2'); - }); }); describe('registerCustomItem', () => { @@ -338,7 +259,7 @@ describe('AppMenuRegistry', () => { expect(config.items?.[1].id).toBe('custom-2'); }); - it('should merge custom items with regular items and sort by order', () => { + it('should merge custom items with regular items', () => { const regularItem: AppMenuItemType = { id: 'regular-item', order: 2, @@ -360,9 +281,6 @@ describe('AppMenuRegistry', () => { const config = registry.getAppMenuConfig(); expect(config.items).toHaveLength(2); - // Should be sorted by order - expect(config.items?.[0].id).toBe('custom-item'); - expect(config.items?.[1].id).toBe('regular-item'); }); }); @@ -395,50 +313,6 @@ describe('AppMenuRegistry', () => { expect(parent?.items?.[0]).toEqual(popoverItem); }); - it('should sort custom popover items by order property', () => { - const parentItem: AppMenuItemType = { - id: 'custom-parent', - order: 1, - label: 'Custom Parent', - iconType: 'beaker', - items: [], - }; - - const popoverItem1: AppMenuPopoverItem = { - id: 'custom-child-1', - label: 'Custom Child 1', - order: 3, - run: jest.fn(), - }; - - const popoverItem2: AppMenuPopoverItem = { - id: 'custom-child-2', - label: 'Custom Child 2', - order: 1, - run: jest.fn(), - }; - - const popoverItem3: AppMenuPopoverItem = { - id: 'custom-child-3', - label: 'Custom Child 3', - order: 2, - run: jest.fn(), - }; - - registry.registerCustomItem(parentItem); - registry.registerCustomPopoverItem('custom-parent', popoverItem1); - registry.registerCustomPopoverItem('custom-parent', popoverItem2); - registry.registerCustomPopoverItem('custom-parent', popoverItem3); - - const config = registry.getAppMenuConfig(); - const parent = config.items?.find((item) => item.id === 'custom-parent'); - - expect(parent?.items).toHaveLength(3); - expect(parent?.items?.[0].id).toBe('custom-child-2'); - expect(parent?.items?.[1].id).toBe('custom-child-3'); - expect(parent?.items?.[2].id).toBe('custom-child-1'); - }); - it('should handle registering custom popover items before parent exists', () => { const popoverItem: AppMenuPopoverItem = { id: 'custom-child-1', @@ -567,7 +441,7 @@ describe('AppMenuRegistry', () => { expect(config.secondaryActionItem).toBeUndefined(); }); - it('should include both regular and custom items in sorted order', () => { + it('should include both regular and custom items', () => { const regularItem: AppMenuItemType = { id: 'regular', order: 2, @@ -590,8 +464,6 @@ describe('AppMenuRegistry', () => { const config = registry.getAppMenuConfig(); expect(config.items).toHaveLength(2); - expect(config.items?.[0].id).toBe('custom'); - expect(config.items?.[1].id).toBe('regular'); }); }); }); diff --git a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts index ad69478819a2a..417df6857336c 100644 --- a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts +++ b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts @@ -62,12 +62,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed = async ( menuButtonTestSubject: string, - isInOverflowMenu: boolean = false + isInOverflowMenu: boolean = false, + hasPopoverItems: boolean = false ) => { if (isInOverflowMenu) { await focusAndPressButton('app-menu-overflow-button'); } - await focusAndPressButton(menuButtonTestSubject); + if (!hasPopoverItems) { + await focusAndPressButton(menuButtonTestSubject); + } + await retry.try(async () => { expect(await hasFocus(menuButtonTestSubject)).to.be(false); }); @@ -85,7 +89,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed('discoverOpenButton')); it('should return focus to the alerts button when dismissing the alerts popover', () => - expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed('discoverAlertsButton')); + expectButtonToLoseAndRegainFocusWhenOverlayIsOpenedAndClosed( + 'discoverAlertsButton', + true, + true + )); it('should return focus to the alerts button when dismissing the create rule flyout', async () => { await testSubjects.click('app-menu-overflow-button'); diff --git a/x-pack/platform/test/functional/apps/discover/group3/rule_creation.ts b/x-pack/platform/test/functional/apps/discover/group3/rule_creation.ts index f5ec14dc5403f..0fe80f5266fbf 100644 --- a/x-pack/platform/test/functional/apps/discover/group3/rule_creation.ts +++ b/x-pack/platform/test/functional/apps/discover/group3/rule_creation.ts @@ -12,12 +12,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Discover rule creation', function () { const { common } = getPageObjects(['common', 'settings', 'shareSavedObjectsToSpace']); const find = getService('find'); + const testSubjects = getService('testSubjects'); it('navigate to Discover', () => { return common.navigateToApp('discover'); }); it('begin creating rule', async () => { + await testSubjects.click('app-menu-overflow-button'); await find.clickByButtonText('Alerts'); await find.clickByButtonText('Create search threshold rule'); await find.clickByButtonText('Details'); diff --git a/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts b/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts index ddd1c586cf19b..1d7d5d54331c5 100644 --- a/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts +++ b/x-pack/platform/test/functional_with_es_ssl/apps/discover_ml/discover/search_source_alert.ts @@ -201,7 +201,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('app-menu-overflow-button'); await testSubjects.click('discoverAlertsButton'); await testSubjects.click('discoverCreateAlertButton'); - await testSubjects.click('app-menu-overflow-button'); }; const openManagementAlertFlyout = async () => { diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts b/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts index a777c1c7bfb27..df3003093b42e 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover_ml_uptime/discover/search_source_alert.ts @@ -236,7 +236,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } else { await testSubjects.click('discoverAppMenuCustomThresholdRule'); } - await testSubjects.click('app-menu-overflow-button'); }; const openManagementAlertFlyout = async () => { From 9edeb2497b4af17956480e2c4034656032bac91c Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Wed, 21 Jan 2026 09:07:37 +0100 Subject: [PATCH 51/76] Reduce flakiness --- ...aved_changes_badge.ts => _unsaved_notification_indicator.ts} | 1 + src/platform/test/functional/apps/discover/group6/index.ts | 2 +- .../discover/group6/_unsaved_changes_notification_indicator.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) rename src/platform/test/functional/apps/discover/group6/{_unsaved_changes_badge.ts => _unsaved_notification_indicator.ts} (99%) diff --git a/src/platform/test/functional/apps/discover/group6/_unsaved_changes_badge.ts b/src/platform/test/functional/apps/discover/group6/_unsaved_notification_indicator.ts similarity index 99% rename from src/platform/test/functional/apps/discover/group6/_unsaved_changes_badge.ts rename to src/platform/test/functional/apps/discover/group6/_unsaved_notification_indicator.ts index 338a550e48baf..00b221ce59255 100644 --- a/src/platform/test/functional/apps/discover/group6/_unsaved_changes_badge.ts +++ b/src/platform/test/functional/apps/discover/group6/_unsaved_notification_indicator.ts @@ -93,6 +93,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should not show a notification indicator after loading a saved search, only after changes', async () => { await discover.loadSavedSearch(SAVED_SEARCH_NAME); await discover.waitUntilTabIsLoaded(); + await discover.waitUntilSearchingHasFinished(); await discover.ensureNoUnsavedChangesIndicator(); diff --git a/src/platform/test/functional/apps/discover/group6/index.ts b/src/platform/test/functional/apps/discover/group6/index.ts index 82d75246c7178..4a6c75398fe37 100644 --- a/src/platform/test/functional/apps/discover/group6/index.ts +++ b/src/platform/test/functional/apps/discover/group6/index.ts @@ -28,7 +28,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_sidebar_field_stats')); loadTestFile(require.resolve('./_time_field_column')); loadTestFile(require.resolve('./_unsaved_changes_modal')); - loadTestFile(require.resolve('./_unsaved_changes_badge')); + loadTestFile(require.resolve('./_unsaved_notification_indicator')); loadTestFile(require.resolve('./_view_mode_toggle')); loadTestFile(require.resolve('./_field_stats_table')); }); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts index eb1f8310aa99a..4b72a556a39b2 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts @@ -95,6 +95,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should not show a notification indicator after loading a saved search, only after changes', async () => { await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); + await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.discover.ensureNoUnsavedChangesIndicator(); From fbb772e29c3f0c4ab2a05601f1da210c55cdc5e4 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Wed, 21 Jan 2026 09:14:46 +0100 Subject: [PATCH 52/76] Reduce a11y test flakiness --- .../apps/discover/group1/_discover_accessibility.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts index 417df6857336c..18632ff092f3d 100644 --- a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts +++ b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts @@ -97,10 +97,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should return focus to the alerts button when dismissing the create rule flyout', async () => { await testSubjects.click('app-menu-overflow-button'); + await testSubjects.existOrFail('discoverAlertsButton'); await focusAndPressButton('discoverAlertsButton'); - expect(await hasFocus('discoverAlertsButton')).to.be(true); + await retry.try(async () => { + expect(await hasFocus('discoverAlertsButton')).to.be(true); + }); await focusAndPressButton('discoverCreateAlertButton'); - expect(await testSubjects.exists('addRuleFlyoutTitle')).to.be(true); + await testSubjects.existOrFail('addRuleFlyoutTitle'); await retry.try(async () => { await browser.pressKeys(browser.keys.ESCAPE); // A bug exists with the create rule flyout where sometimes the confirm modal From 42fa70f0bb09c1860415bc4bb99d03c71fd8cb7c Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Wed, 21 Jan 2026 10:06:01 +0100 Subject: [PATCH 53/76] Flakiness fix --- .../apps/discover/group1/_discover_accessibility.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts index 18632ff092f3d..6ae0879d15162 100644 --- a/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts +++ b/src/platform/test/functional/apps/discover/group1/_discover_accessibility.ts @@ -98,11 +98,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should return focus to the alerts button when dismissing the create rule flyout', async () => { await testSubjects.click('app-menu-overflow-button'); await testSubjects.existOrFail('discoverAlertsButton'); - await focusAndPressButton('discoverAlertsButton'); - await retry.try(async () => { - expect(await hasFocus('discoverAlertsButton')).to.be(true); - }); - await focusAndPressButton('discoverCreateAlertButton'); + await testSubjects.click('discoverAlertsButton'); + await testSubjects.existOrFail('discoverCreateAlertButton'); + await testSubjects.click('discoverCreateAlertButton'); await testSubjects.existOrFail('addRuleFlyoutTitle'); await retry.try(async () => { await browser.pressKeys(browser.keys.ESCAPE); From 7347575edb04e2bc212313a404312d971db00f07 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Wed, 21 Jan 2026 17:37:01 +0100 Subject: [PATCH 54/76] Flakiness fix --- .../_unsaved_changes_notification_indicator.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts index 4b72a556a39b2..09eee104a79ba 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts @@ -16,6 +16,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const dataGrid = getService('dataGrid'); const filterBar = getService('filterBar'); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects([ 'common', 'svlCommonPage', @@ -31,6 +33,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { hideAnnouncements: true, }; + const loadSavedSearchWithRetry = async (searchName: string) => { + await PageObjects.discover.openLoadSavedSearchPanel(); + const searchItemTestSubj = `savedObjectTitle${searchName.split(' ').join('-')}`; + await retry.waitFor(`saved search "${searchName}" to appear in the list`, async () => { + return await testSubjects.exists(searchItemTestSubj); + }); + await testSubjects.click(searchItemTestSubj); + await PageObjects.header.waitUntilLoadingHasFinished(); + }; + describe('discover unsaved changes notification indicator', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); @@ -93,7 +105,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should not show a notification indicator after loading a saved search, only after changes', async () => { - await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); + await loadSavedSearchWithRetry(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.discover.waitUntilSearchingHasFinished(); @@ -107,7 +119,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should allow to revert changes', async () => { - await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); + await loadSavedSearchWithRetry(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.discover.ensureNoUnsavedChangesIndicator(); @@ -152,7 +164,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should hide the notification indicator once user manually reverts changes', async () => { - await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); + await loadSavedSearchWithRetry(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.discover.ensureNoUnsavedChangesIndicator(); From f5e47667801d3dcfc14a21e00b4daae7f9f8956c Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 09:10:17 +0100 Subject: [PATCH 55/76] CR changes --- .../src/components/app_menu/types.ts | 2 +- .../main/components/tabs_view/tabs_view.tsx | 90 ++------------ .../components/tabs_view/use_app_menu_data.ts | 111 ++++++++++++++++++ .../top_nav/app_menu_actions/get_inspect.tsx | 4 +- .../top_nav/app_menu_actions/get_share.tsx | 64 ++++------ .../app_menu_actions/run_app_menu_action.tsx | 45 +++---- .../components/top_nav/use_discover_topnav.ts | 12 +- .../components/top_nav/use_top_nav_links.tsx | 4 +- .../top_nav_customization.ts | 5 - ...unsaved_changes_notification_indicator.ts} | 0 .../functional/apps/discover/group6/index.ts | 2 +- .../apps/discover/tabs2/_unsaved_changes.ts | 2 +- .../apps/discover/tabs3/_time_range.ts | 4 +- 13 files changed, 177 insertions(+), 168 deletions(-) create mode 100644 src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts rename src/platform/test/functional/apps/discover/group6/{_unsaved_notification_indicator.ts => _unsaved_changes_notification_indicator.ts} (100%) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts index 4aba9fa090d6e..b51c6f6243678 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts @@ -48,7 +48,7 @@ export interface DiscoverAppMenuRunActionParams extends AppMenuRunActionParams { */ export type DiscoverAppMenuRunAction = ( params: DiscoverAppMenuRunActionParams -) => ReactElement | void | null | ReactNode; +) => ReactElement | void | null | ReactNode | Promise; /** * Discover-specific popover item with typed run action diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx index 9632e5144b04b..b9e386752bffc 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx @@ -7,17 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useContext, useMemo, useState } from 'react'; -import { EuiResizeObserver, type EuiResizeObserverProps } from '@elastic/eui'; -import { UnifiedTabs, type UnifiedTabsProps, type TabMenuItem } from '@kbn/unified-tabs'; -import useObservable from 'react-use/lib/useObservable'; -import { AppMenuComponent, type AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; -import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import { METRIC_TYPE } from '@kbn/analytics'; -import { ENABLE_ESQL } from '@kbn/esql-utils'; +import React, { useCallback } from 'react'; +import { EuiResizeObserver } from '@elastic/eui'; +import { UnifiedTabs, type UnifiedTabsProps } from '@kbn/unified-tabs'; +import { AppMenuComponent } from '@kbn/core-chrome-app-menu-components'; import { SingleTabView, type SingleTabViewProps } from '../single_tab_view'; -import { discoverTopNavMenuContext } from '../top_nav/discover_topnav_menu'; import { createTabItem, internalStateActions, @@ -27,52 +21,34 @@ import { useInternalStateDispatch, useInternalStateSelector, useCurrentTabRuntimeState, - useCurrentTabAction, } from '../../state_management/redux'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { usePreviewData } from './use_preview_data'; -import { useIsEsqlMode } from '../../hooks/use_is_esql_mode'; -import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; +import { useAppMenuData } from './use_app_menu_data'; const MAX_TABS_COUNT = 25; -const APP_MENU_COLLAPSE_THRESHOLD = 800; export const TabsView = (props: SingleTabViewProps) => { const services = useDiscoverServices(); const dispatch = useInternalStateDispatch(); const items = useInternalStateSelector(selectAllTabs); - const [shouldCollapseAppMenu, setShouldCollapseAppMenu] = useState(false); const recentlyClosedItems = useInternalStateSelector(selectRecentlyClosedTabs); const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId); const { getPreviewData } = usePreviewData(props.runtimeStateManager); const hideTabsBar = useInternalStateSelector(selectIsTabsBarHidden); const unsavedTabIds = useInternalStateSelector((state) => state.tabs.unsavedIds); - const isEsqlMode = useIsEsqlMode(); const currentDataView = useCurrentTabRuntimeState( props.runtimeStateManager, (tab) => tab.currentDataView$ ); - const transitionFromESQLToDataView = useCurrentTabAction( - internalStateActions.transitionFromESQLToDataView - ); - - // Determine if we should show the ES|QL to Data View transition modal - const persistedDiscoverSession = useInternalStateSelector( - (state) => state.persistedDiscoverSession - ); - const shouldShowESQLToDataViewTransitionModal = - !persistedDiscoverSession || unsavedTabIds.includes(currentTabId); - const scopedEbtManager = useCurrentTabRuntimeState( props.runtimeStateManager, (state) => state.scopedEbtManager$ ); - const onResize: EuiResizeObserverProps['onResize'] = useCallback((dimensions) => { - if (!dimensions) return; - setShouldCollapseAppMenu(dimensions.width < APP_MENU_COLLAPSE_THRESHOLD); - }, []); + const { shouldCollapseAppMenu, onResize, getAdditionalTabMenuItems, topNavMenuItems } = + useAppMenuData({ currentDataView }); const onEvent: UnifiedTabsProps['onEBTEvent'] = useCallback( (event) => { @@ -101,44 +77,6 @@ export const TabsView = (props: SingleTabViewProps) => { [currentTabId, props] ); - // Provide "Switch to Classic" menu item for tabs when in ES|QL mode - const getAdditionalTabMenuItems: UnifiedTabsProps['getAdditionalTabMenuItems'] = useMemo(() => { - if (!isEsqlMode || !services.uiSettings.get(ENABLE_ESQL)) { - return undefined; - } - - return (): TabMenuItem[] => [ - { - 'data-test-subj': 'unifiedTabs_tabMenuItem_switchToClassic', - name: 'switchToClassic', - label: i18n.translate('discover.localMenu.switchToClassicTitle', { - defaultMessage: 'Switch to classic', - }), - onClick: () => { - services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:back_to_classic_clicked`); - if ( - shouldShowESQLToDataViewTransitionModal && - !services.storage.get(ESQL_TRANSITION_MODAL_KEY) - ) { - dispatch(internalStateActions.setIsESQLToDataViewTransitionModalVisible(true)); - } else { - dispatch(transitionFromESQLToDataView({ dataViewId: currentDataView?.id ?? '' })); - } - }, - }, - ]; - }, [ - isEsqlMode, - services, - shouldShowESQLToDataViewTransitionModal, - dispatch, - transitionFromESQLToDataView, - currentDataView, - ]); - - const { topNavMenu$ } = useContext(discoverTopNavMenuContext); - const topNavMenuItems = useObservable(topNavMenu$, topNavMenu$.getValue()); - return ( /** * AppMenuComponent handles responsiveness on its own, however, there are some edge cases e.g opening push flyout @@ -146,14 +84,7 @@ export const TabsView = (props: SingleTabViewProps) => { */ {(resizeRef) => ( -
+
{ onClearRecentlyClosed={onClearRecentlyClosed} getAdditionalTabMenuItems={getAdditionalTabMenuItems} appendRight={ - + } />
diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts new file mode 100644 index 0000000000000..74b00c24e5713 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts @@ -0,0 +1,111 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useCallback, useContext, useMemo, useState } from 'react'; +import type { EuiResizeObserverProps } from '@elastic/eui'; +import type { UnifiedTabsProps, TabMenuItem } from '@kbn/unified-tabs'; +import useObservable from 'react-use/lib/useObservable'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; +import { i18n } from '@kbn/i18n'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { ENABLE_ESQL } from '@kbn/esql-utils'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { discoverTopNavMenuContext } from '../top_nav/discover_topnav_menu'; +import { + internalStateActions, + useInternalStateDispatch, + useInternalStateSelector, + useCurrentTabAction, +} from '../../state_management/redux'; +import { useDiscoverServices } from '../../../../hooks/use_discover_services'; +import { useIsEsqlMode } from '../../hooks/use_is_esql_mode'; +import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; + +const APP_MENU_COLLAPSE_THRESHOLD = 800; + +interface UseAppMenuDataParams { + currentDataView: DataView | undefined; +} + +interface UseAppMenuDataResult { + shouldCollapseAppMenu: boolean; + onResize: EuiResizeObserverProps['onResize']; + getAdditionalTabMenuItems: UnifiedTabsProps['getAdditionalTabMenuItems']; + topNavMenuItems: AppMenuConfig; +} + +export const useAppMenuData = ({ currentDataView }: UseAppMenuDataParams): UseAppMenuDataResult => { + const services = useDiscoverServices(); + const dispatch = useInternalStateDispatch(); + const isEsqlMode = useIsEsqlMode(); + const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId); + const unsavedTabIds = useInternalStateSelector((state) => state.tabs.unsavedIds); + const [shouldCollapseAppMenu, setShouldCollapseAppMenu] = useState(false); + + const transitionFromESQLToDataView = useCurrentTabAction( + internalStateActions.transitionFromESQLToDataView + ); + + // Determine if we should show the ES|QL to Data View transition modal + const persistedDiscoverSession = useInternalStateSelector( + (state) => state.persistedDiscoverSession + ); + const shouldShowESQLToDataViewTransitionModal = + !persistedDiscoverSession || unsavedTabIds.includes(currentTabId); + + const onResize: EuiResizeObserverProps['onResize'] = useCallback((dimensions) => { + if (!dimensions) return; + setShouldCollapseAppMenu(dimensions.width < APP_MENU_COLLAPSE_THRESHOLD); + }, []); + + // Provide "Switch to Classic" menu item for tabs when in ES|QL mode + const getAdditionalTabMenuItems: UnifiedTabsProps['getAdditionalTabMenuItems'] = useMemo(() => { + if (!isEsqlMode || !services.uiSettings.get(ENABLE_ESQL)) { + return undefined; + } + + return (): TabMenuItem[] => [ + { + 'data-test-subj': 'unifiedTabs_tabMenuItem_switchToClassic', + name: 'switchToClassic', + label: i18n.translate('discover.localMenu.switchToClassicTitle', { + defaultMessage: 'Switch to classic', + }), + onClick: () => { + services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:back_to_classic_clicked`); + if ( + shouldShowESQLToDataViewTransitionModal && + !services.storage.get(ESQL_TRANSITION_MODAL_KEY) + ) { + dispatch(internalStateActions.setIsESQLToDataViewTransitionModalVisible(true)); + } else { + dispatch(transitionFromESQLToDataView({ dataViewId: currentDataView?.id ?? '' })); + } + }, + }, + ]; + }, [ + isEsqlMode, + services, + shouldShowESQLToDataViewTransitionModal, + dispatch, + transitionFromESQLToDataView, + currentDataView, + ]); + + const { topNavMenu$ } = useContext(discoverTopNavMenuContext); + const topNavMenuItems = useObservable(topNavMenu$, topNavMenu$.getValue()); + + return { + shouldCollapseAppMenu, + onResize, + getAdditionalTabMenuItems, + topNavMenuItems: topNavMenuItems as AppMenuConfig, + }; +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx index 8f03e686d3d83..59effd37c3375 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_inspect.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; +import { AppMenuActionId, type DiscoverAppMenuItemType } from '@kbn/discover-utils'; import { i18n } from '@kbn/i18n'; export const getInspectAppMenuItem = ({ @@ -16,7 +16,7 @@ export const getInspectAppMenuItem = ({ onOpenInspector: (onClose?: () => void) => void; }): DiscoverAppMenuItemType => { return { - id: 'inspect', + id: AppMenuActionId.inspect, iconType: 'inspect', order: 7, label: i18n.translate('discover.localMenu.inspectTitle', { diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 8af67b01dfeb4..34f6fc45bcaca 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -16,7 +16,7 @@ import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import type { AppMenuItemType, AppMenuPopoverItem } from '@kbn/core-chrome-app-menu-components'; import type { ShowShareMenuOptions } from '@kbn/share-plugin/public'; import type { IntlShape } from '@kbn/i18n-react'; -import type { ShareActionIntents } from '@kbn/share-plugin/public/types'; +import type { ShareIntegration } from '@kbn/share-plugin/public/types'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; import type { DataTotalHitsMsg } from '../../../state_management/discover_data_state_container'; import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data'; @@ -25,6 +25,16 @@ import type { AppMenuDiscoverParams } from './types'; import type { DiscoverServices } from '../../../../../build_services'; import type { TabState } from '../../../state_management/redux/types'; +interface BuildShareOptionsParams { + discoverParams: AppMenuDiscoverParams; + services: DiscoverServices; + stateContainer: DiscoverStateContainer; + currentTab: TabState; + persistedDiscoverSession: DiscoverSession | undefined; + totalHitsState: DataTotalHitsMsg; + hasUnsavedChanges: boolean; +} + /** * Builds share options for both share modal and export integrations */ @@ -36,15 +46,7 @@ const buildShareOptions = async ({ persistedDiscoverSession, totalHitsState, hasUnsavedChanges, -}: { - discoverParams: AppMenuDiscoverParams; - services: DiscoverServices; - stateContainer: DiscoverStateContainer; - currentTab: TabState; - persistedDiscoverSession: DiscoverSession | undefined; - totalHitsState: DataTotalHitsMsg; - hasUnsavedChanges: boolean; -}): Promise> => { +}: BuildShareOptionsParams): Promise> => { const { dataView, isEsqlMode } = discoverParams; const searchSourceSharingData = await getSharingData( @@ -151,15 +153,7 @@ const buildShareOptions = async ({ * Generates export menu items from available share integrations */ const getExportItems = ( - buildShareOptionsParams: { - discoverParams: AppMenuDiscoverParams; - services: DiscoverServices; - stateContainer: DiscoverStateContainer; - currentTab: TabState; - persistedDiscoverSession: DiscoverSession | undefined; - totalHitsState: DataTotalHitsMsg; - hasUnsavedChanges: boolean; - }, + buildShareOptionsParams: BuildShareOptionsParams, intl: IntlShape ): AppMenuPopoverItem[] => { const { services } = buildShareOptionsParams; @@ -199,11 +193,8 @@ const getExportItems = ( }; const exportItems: AppMenuPopoverItem[] = exportIntegrations - .filter( - (item: ShareActionIntents): item is typeof item & { shareType: 'integration'; id: string } => - item.shareType === 'integration' - ) - .map((item: ShareActionIntents & { shareType: 'integration'; id: string }) => ({ + .filter((item): item is ShareIntegration => item.shareType === 'integration') + .map((item) => ({ ...mapIntegrationToMetaData(item.id), id: item.id, run: async () => { @@ -212,25 +203,20 @@ const getExportItems = ( await handler?.(); }, })); - const derivativeItems: AppMenuPopoverItem[] = exportDerivatives .filter( - ( - item: ShareActionIntents - ): item is typeof item & { shareType: 'integration'; id: string; groupId: string } => + (item): item is ShareIntegration => item.shareType === 'integration' && item.groupId === 'exportDerivatives' ) - .map( - (item: ShareActionIntents & { shareType: 'integration'; id: string; groupId: string }) => ({ - ...mapIntegrationToMetaData(item.id), - id: item.id, - run: async () => { - const shareOptions = await buildShareOptions(buildShareOptionsParams); - const handler = await services.share?.getExportDerivativeHandler(shareOptions, item.id); - await handler?.(); - }, - }) - ); + .map((item) => ({ + ...mapIntegrationToMetaData(item.id), + id: item.id, + run: async () => { + const shareOptions = await buildShareOptions(buildShareOptionsParams); + const handler = await services.share?.getExportDerivativeHandler(shareOptions, item.id); + await handler?.(); + }, + })); return [...exportItems, ...derivativeItems]; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx index fd443d9be8a3d..99c6d0e11f7a4 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx @@ -33,19 +33,7 @@ import type { DiscoverServices } from '../../../../../build_services'; const container = document.createElement('div'); let isOpen = false; -function cleanup(anchorElement?: HTMLElement, parentTestId?: string) { - if (!isOpen) { - return; - } - - // Check if anchor is in DOM before we remove the container - const shouldFocusAnchor = anchorElement && document.body.contains(anchorElement); - - ReactDOM.unmountComponentAtNode(container); - document.body.removeChild(container); - isOpen = false; - - // Restore focus using the captured state +function restoreFocus(anchorElement?: HTMLElement, parentTestId?: string) { const overflowButton = document.querySelector( '[data-test-subj="app-menu-overflow-button"]' ) as HTMLElement; @@ -55,13 +43,25 @@ function cleanup(anchorElement?: HTMLElement, parentTestId?: string) { `[data-test-subj="${parentTestId}"]` ) as HTMLElement; (parentButton || overflowButton)?.focus(); - } else if (shouldFocusAnchor) { - anchorElement!.focus(); + } else if (anchorElement && document.body.contains(anchorElement)) { + anchorElement.focus(); } else { overflowButton?.focus(); } } +function cleanup(anchorElement?: HTMLElement, parentTestId?: string) { + if (!isOpen) { + return; + } + + ReactDOM.unmountComponentAtNode(container); + document.body.removeChild(container); + isOpen = false; + + restoreFocus(anchorElement, parentTestId); +} + export async function runAppMenuAction({ appMenuItem, anchorElement, @@ -83,20 +83,7 @@ export async function runAppMenuAction({ cleanup(anchorElement, parentTestId); // If cleanup didn't run (no React element), still restore focus if (!isOpen) { - const overflowButton = document.querySelector( - '[data-test-subj="app-menu-overflow-button"]' - ) as HTMLElement; - - if (parentTestId) { - const parentButton = document.querySelector( - `[data-test-subj="${parentTestId}"]` - ) as HTMLElement; - (parentButton || overflowButton)?.focus(); - } else if (anchorElement && document.body.contains(anchorElement)) { - anchorElement.focus(); - } else { - overflowButton?.focus(); - } + restoreFocus(anchorElement, parentTestId); } }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts index e96c8e462105e..eea80e77aba56 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts @@ -47,11 +47,13 @@ export const useDiscoverTopNav = ({ ); useEffect(() => { - services.chrome.setBreadcrumbsBadges(topNavBadges); - return () => { - services.chrome.setBreadcrumbsBadges([]); - }; - }, [topNavBadges, services.chrome]); + if (stateContainer.customizationContext.displayMode === 'standalone') { + services.chrome.setBreadcrumbsBadges(topNavBadges); + return () => { + services.chrome.setBreadcrumbsBadges([]); + }; + } + }, [topNavBadges, services.chrome, stateContainer.customizationContext.displayMode]); const dataView = useCurrentDataView(); const adHocDataViews = useAdHocDataViews(); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 0b124f51773b3..1cdff89381e98 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -266,8 +266,8 @@ export const useTopNavLinks = ({ }), testId: 'discoverSaveButton', iconType: 'save', - run: () => { - onSaveDiscoverSession({ + run: async () => { + await onSaveDiscoverSession({ services, state, }); diff --git a/src/platform/plugins/shared/discover/public/customizations/customization_types/top_nav_customization.ts b/src/platform/plugins/shared/discover/public/customizations/customization_types/top_nav_customization.ts index 1bbc6adee520f..ab2be7acb6147 100644 --- a/src/platform/plugins/shared/discover/public/customizations/customization_types/top_nav_customization.ts +++ b/src/platform/plugins/shared/discover/public/customizations/customization_types/top_nav_customization.ts @@ -20,12 +20,7 @@ export interface TopNavDefaultMenu { saveItem?: TopNavDefaultItem; } -export interface TopNavDefaultBadges { - unsavedChangesBadge?: TopNavDefaultItem; -} - export interface TopNavCustomization { id: 'top_nav'; defaultMenu?: TopNavDefaultMenu; - defaultBadges?: TopNavDefaultBadges; } diff --git a/src/platform/test/functional/apps/discover/group6/_unsaved_notification_indicator.ts b/src/platform/test/functional/apps/discover/group6/_unsaved_changes_notification_indicator.ts similarity index 100% rename from src/platform/test/functional/apps/discover/group6/_unsaved_notification_indicator.ts rename to src/platform/test/functional/apps/discover/group6/_unsaved_changes_notification_indicator.ts diff --git a/src/platform/test/functional/apps/discover/group6/index.ts b/src/platform/test/functional/apps/discover/group6/index.ts index 4a6c75398fe37..20931710f975b 100644 --- a/src/platform/test/functional/apps/discover/group6/index.ts +++ b/src/platform/test/functional/apps/discover/group6/index.ts @@ -28,7 +28,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_sidebar_field_stats')); loadTestFile(require.resolve('./_time_field_column')); loadTestFile(require.resolve('./_unsaved_changes_modal')); - loadTestFile(require.resolve('./_unsaved_notification_indicator')); + loadTestFile(require.resolve('./_unsaved_changes_notification_indicator')); loadTestFile(require.resolve('./_view_mode_toggle')); loadTestFile(require.resolve('./_field_stats_table')); }); diff --git a/src/platform/test/functional/apps/discover/tabs2/_unsaved_changes.ts b/src/platform/test/functional/apps/discover/tabs2/_unsaved_changes.ts index 072930705988b..07e74380400dd 100644 --- a/src/platform/test/functional/apps/discover/tabs2/_unsaved_changes.ts +++ b/src/platform/test/functional/apps/discover/tabs2/_unsaved_changes.ts @@ -40,7 +40,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await discover.hasUnsavedChangesIndicator()).to.be(true); }); - it('clears unsaved changes badge on session save', async () => { + it('clears unsaved changes indicator on session save', async () => { const SEARCH_NAME = `unsaved_changes_${Date.now()}`; await discover.saveSearch(SEARCH_NAME); diff --git a/src/platform/test/functional/apps/discover/tabs3/_time_range.ts b/src/platform/test/functional/apps/discover/tabs3/_time_range.ts index 7667edf011798..24f52d2842ffe 100644 --- a/src/platform/test/functional/apps/discover/tabs3/_time_range.ts +++ b/src/platform/test/functional/apps/discover/tabs3/_time_range.ts @@ -121,7 +121,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await timePicker.startAutoRefresh(30); await discover.waitUntilTabIsLoaded(); await checkUpdatedTimeConfiguration(); - // changing the time range shouldn't trigger the unsaved changes badge for a discover session with a disabled time range setting + // changing the time range shouldn't trigger the unsaved changes indicator for a discover session with a disabled time range setting expect(await discover.hasUnsavedChangesIndicator()).to.be(false); await unifiedTabs.selectTab(0); @@ -151,7 +151,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await timePicker.setAbsoluteRange(updatedTimeRange.start, updatedTimeRange.end); await discover.waitUntilTabIsLoaded(); - // changing the time range should trigger the unsaved changes badge for a discover session with an enabled time range setting + // changing the time range should trigger the unsaved changes indicator for a discover session with an enabled time range setting expect(await discover.hasUnsavedChangesIndicator()).to.be(true); }); }); From 2211152b622979b56a0897d78a088d335af710da Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 09:24:06 +0100 Subject: [PATCH 56/76] CR changes --- .../top_nav/use_top_nav_links.test.tsx | 16 +++++++++++++++- .../example_data_source_profile/profile.tsx | 3 ++- .../example/example_root_profile/profile.tsx | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index a6f40a5c2953f..c9915eb5c4f78 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -103,12 +103,26 @@ describe('useTopNavLinks', () => { }); describe('when ES|QL mode is true', () => { - it('should return results', () => { + it('should NOT include the esql secondary action item', () => { const appMenuConfig = setup({ isEsqlMode: true, }); expect(appMenuConfig.items).toBeDefined(); + expect(appMenuConfig.secondaryActionItem).toBeUndefined(); + }); + }); + + describe('when ES|QL mode is false (classic mode)', () => { + it('should include the esql secondary action item', () => { + const appMenuConfig = setup({ + isEsqlMode: false, + }); + + expect(appMenuConfig.items).toBeDefined(); + expect(appMenuConfig.secondaryActionItem).toBeDefined(); + expect(appMenuConfig.secondaryActionItem?.id).toBe('esql'); + expect(appMenuConfig.secondaryActionItem?.label).toBe('ES|QL'); }); }); diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx index 1dd84d0912f45..2100d01818400 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_data_source_profile/profile.tsx @@ -111,7 +111,8 @@ export const createExampleDataSourceProfileProvider = (): DataSourceProfileProvi }; }, /** - * The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomAction and registerCustomActionUnderSubmenu. + * The `getAppMenu` extension point gives access to AppMenuRegistry with methods `registerCustomItem` and + * `registerCustomPopoverItem`. * The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation. * And it supports opening custom flyouts and any other modals on the click. * `getAppMenu` can be configured in both root and data source profiles. diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx index 49774a5932111..59cdccccded49 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/example/example_root_profile/profile.tsx @@ -40,8 +40,8 @@ export const createExampleRootProfileProvider = (): RootProfileProvider => ({ }, }), /** - * The `getAppMenu` extension point gives access to AppMenuRegistry with methods registerCustomItem and - * registerCustomPopoverItem. + * The `getAppMenu` extension point gives access to AppMenuRegistry with methods `registerCustomItem` and + * `registerCustomPopoverItem`. * The extension also provides the essential params like current dataView, adHocDataViews etc when defining a custom action implementation. * And it supports opening custom flyouts and any other modals on the click. * `getAppMenu` can be configured in both root and data source profiles. From 17e8000ea8d49c14fef4c4242d9f8c6dc189f7c9 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 10:06:47 +0100 Subject: [PATCH 57/76] Revert type change --- .../top_nav/app_menu_actions/get_share.tsx | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 34f6fc45bcaca..6b40db94d6035 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -16,7 +16,7 @@ import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import type { AppMenuItemType, AppMenuPopoverItem } from '@kbn/core-chrome-app-menu-components'; import type { ShowShareMenuOptions } from '@kbn/share-plugin/public'; import type { IntlShape } from '@kbn/i18n-react'; -import type { ShareIntegration } from '@kbn/share-plugin/public/types'; +import type { ShareActionIntents } from '@kbn/share-plugin/public/types'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; import type { DataTotalHitsMsg } from '../../../state_management/discover_data_state_container'; import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data'; @@ -193,8 +193,11 @@ const getExportItems = ( }; const exportItems: AppMenuPopoverItem[] = exportIntegrations - .filter((item): item is ShareIntegration => item.shareType === 'integration') - .map((item) => ({ + .filter( + (item: ShareActionIntents): item is typeof item & { shareType: 'integration'; id: string } => + item.shareType === 'integration' + ) + .map((item: ShareActionIntents & { shareType: 'integration'; id: string }) => ({ ...mapIntegrationToMetaData(item.id), id: item.id, run: async () => { @@ -203,20 +206,25 @@ const getExportItems = ( await handler?.(); }, })); + const derivativeItems: AppMenuPopoverItem[] = exportDerivatives .filter( - (item): item is ShareIntegration => + ( + item: ShareActionIntents + ): item is typeof item & { shareType: 'integration'; id: string; groupId: string } => item.shareType === 'integration' && item.groupId === 'exportDerivatives' ) - .map((item) => ({ - ...mapIntegrationToMetaData(item.id), - id: item.id, - run: async () => { - const shareOptions = await buildShareOptions(buildShareOptionsParams); - const handler = await services.share?.getExportDerivativeHandler(shareOptions, item.id); - await handler?.(); - }, - })); + .map( + (item: ShareActionIntents & { shareType: 'integration'; id: string; groupId: string }) => ({ + ...mapIntegrationToMetaData(item.id), + id: item.id, + run: async () => { + const shareOptions = await buildShareOptions(buildShareOptionsParams); + const handler = await services.share?.getExportDerivativeHandler(shareOptions, item.id); + await handler?.(); + }, + }) + ); return [...exportItems, ...derivativeItems]; }; From 84a22021438473105769c7da26b72d0528db33b5 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 10:18:00 +0100 Subject: [PATCH 58/76] Rework onNewSearch --- .../app_menu_actions/get_new_search.tsx | 11 +++------ .../components/top_nav/use_top_nav_links.tsx | 24 ++++--------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx index 827f9f2264536..c255e0bcc0235 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx @@ -12,11 +12,9 @@ import { AppMenuActionId } from '@kbn/discover-utils'; import { i18n } from '@kbn/i18n'; export const getNewSearchAppMenuItem = ({ - onNewSearch, - onNavigate, + newSearchUrl, }: { - onNewSearch: () => void; - onNavigate: () => void; + newSearchUrl: string; }): DiscoverAppMenuItemType => { return { id: AppMenuActionId.new, @@ -26,9 +24,6 @@ export const getNewSearchAppMenuItem = ({ }), iconType: 'plusInCircle', testId: 'discoverNewButton', - run: () => { - onNewSearch(); - onNavigate(); - }, + href: newSearchUrl, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 1cdff89381e98..7bb9d0ac3a626 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -20,13 +20,11 @@ import type { } from '@kbn/discover-utils'; import { AppMenuRegistry, dismissFlyouts, DiscoverFlyouts } from '@kbn/discover-utils'; import { ESQL_TYPE } from '@kbn/data-view-utils'; -import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics'; import type { RuleTypeWithDescription } from '@kbn/alerts-ui-shared'; import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared'; import useObservable from 'react-use/lib/useObservable'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import { useI18n } from '@kbn/i18n-react'; -import { createDataViewDataSource } from '../../../../../common/data_sources'; import type { DiscoverServices } from '../../../../build_services'; import type { DiscoverStateContainer } from '../../state_management/discover_state'; import type { AppMenuDiscoverParams } from './app_menu_actions'; @@ -48,7 +46,6 @@ import { useCurrentTabSelector, useInternalStateDispatch, } from '../../state_management/redux'; -import type { DiscoverAppLocatorParams } from '../../../../../common'; import type { DiscoverAppState } from '../../state_management/redux'; import { onSaveDiscoverSession } from './save_discover_session'; import { useDataState } from '../../hooks/use_data_state'; @@ -162,23 +159,12 @@ export const useTopNavLinks = ({ isEsqlMode && currentDataView.type === ESQL_TYPE ? { query: { esql: getInitialESQLQuery(currentDataView, true) } } : undefined; - const locatorParams: DiscoverAppLocatorParams = defaultEsqlState - ? defaultEsqlState - : currentDataView.isPersisted() - ? { dataViewId: currentDataView.id } - : { dataViewSpec: currentDataView.toMinimalSpec() }; + const locatorParams = defaultEsqlState ?? { + dataViewId: currentDataView.id || undefined, + }; + const newSearchUrl = services.locator.getRedirectUrl(locatorParams); const newSearchMenuItem = getNewSearchAppMenuItem({ - onNewSearch: () => { - const defaultState: DiscoverAppState = defaultEsqlState ?? { - dataSource: currentDataView.id - ? createDataViewDataSource({ dataViewId: currentDataView.id }) - : undefined, - }; - services.application.navigateToApp(DISCOVER_APP_ID, { state: { defaultState } }); - }, - onNavigate: () => { - services.locator.navigate(locatorParams); - }, + newSearchUrl, }); items.push(newSearchMenuItem); } From 84316ecf3a935a7b714ebcb4dd8d9dfda0803c6f Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 10:44:22 +0100 Subject: [PATCH 59/76] Handle single tab view --- .../single_tab_view_with_app_menu.tsx | 29 +++++++++++++++++++ .../top_nav/discover_topnav_menu.tsx | 15 +--------- .../top_nav/use_top_nav_menu_items.ts | 24 +++++++++++++++ .../application/main/discover_main_route.tsx | 4 +-- 4 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx create mode 100644 src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_menu_items.ts diff --git a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx new file mode 100644 index 0000000000000..cbacfffcf7927 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; +import { AppMenu } from '@kbn/core-chrome-app-menu'; +import { SingleTabView, type SingleTabViewProps } from '.'; +import { useDiscoverServices } from '../../../../hooks/use_discover_services'; +import { useTopNavMenuItems } from '../top_nav/use_top_nav_menu_items'; + +export const SingleTabViewWithAppMenu = (props: SingleTabViewProps) => { + const { chrome } = useDiscoverServices(); + const topNavMenuItems = useTopNavMenuItems(); + + return ( + <> + {topNavMenuItems && ( + + )} + + + ); +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx index 18e4fdf176bea..147ac13f45c83 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx @@ -17,11 +17,7 @@ import React, { import { BehaviorSubject } from 'rxjs'; import useUnmount from 'react-use/lib/useUnmount'; import type { DiscoverAppMenuConfig } from '@kbn/discover-utils'; -import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; -import { AppMenu } from '@kbn/core-chrome-app-menu'; import type { useDiscoverTopNav } from './use_discover_topnav'; -import { useDiscoverServices } from '../../../../hooks/use_discover_services'; -import { useDiscoverCustomization } from '../../../../customizations'; /** * We handle the top nav menu this way because we need to render it higher in the tree than @@ -58,19 +54,10 @@ export const DiscoverTopNavMenu = ({ topNavMenu, }: Pick, 'topNavMenu'>) => { const { topNavMenu$ } = useContext(discoverTopNavMenuContext); - const { chrome } = useDiscoverServices(); - const topNavCustomization = useDiscoverCustomization('top_nav'); useLayoutEffect(() => { topNavMenu$.next(topNavMenu); }, [topNavMenu, topNavMenu$]); - /** - * Render app menu for SingleTabView when customizations exist. - */ - if (!topNavCustomization) { - return null; - } - - return ; + return null; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_menu_items.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_menu_items.ts new file mode 100644 index 0000000000000..23d7f478bd6a7 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_menu_items.ts @@ -0,0 +1,24 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useContext } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { discoverTopNavMenuContext } from './discover_topnav_menu'; + +/** + * Hook to access the top nav menu items from context. + * This provides a shared way to get the menu items in both + * TabsView and SingleTabView scenarios. + */ +export const useTopNavMenuItems = () => { + const { topNavMenu$ } = useContext(discoverTopNavMenuContext); + const topNavMenuItems = useObservable(topNavMenu$, topNavMenu$.getValue()); + + return topNavMenuItems; +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx index 27f22e7bee9e2..a9d21268ad4d3 100644 --- a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx @@ -31,10 +31,10 @@ import { useRootProfile, useDefaultAdHocDataViews } from '../../context_awarenes import type { SingleTabViewProps } from './components/single_tab_view'; import { BrandedLoadingIndicator, - SingleTabView, NoDataPage, InitializationError, } from './components/single_tab_view'; +import { SingleTabViewWithAppMenu } from './components/single_tab_view/single_tab_view_with_app_menu'; import { useAsyncFunction } from './hooks/use_async_function'; import { TabsView } from './components/tabs_view'; import { ChartPortalsRenderer } from './components/chart'; @@ -267,7 +267,7 @@ const DiscoverMainRouteContent = (props: SingleTabViewProps) => { {tabsEnabled && customizationContext.displayMode !== 'embedded' ? ( ) : ( - + )} From df28c21e5d466bae6f861bedef305355d57df79d Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 11:18:48 +0100 Subject: [PATCH 60/76] Improve types --- .../kbn-discover-utils/src/components/app_menu/types.ts | 4 +--- .../main/components/top_nav/use_top_nav_links.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts index b51c6f6243678..f4349fa407999 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/types.ts @@ -83,9 +83,7 @@ export type DiscoverAppMenuSecondaryActionItem = Omit; + items?: DiscoverAppMenuItemType[]; primaryActionItem?: DiscoverAppMenuPrimaryActionItem; secondaryActionItem?: DiscoverAppMenuSecondaryActionItem; } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 5bfebbb199d00..1cf2e78d3d8e6 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -341,8 +341,12 @@ export const useTopNavLinks = ({ const config = appMenuRegistry.getAppMenuConfig(); return { - items: config.items?.map((item) => - enhanceAppMenuItemWithRunAction({ appMenuItem: item, services }) + items: config.items?.map( + (item) => + enhanceAppMenuItemWithRunAction({ + appMenuItem: item, + services, + }) as DiscoverAppMenuItemType ), primaryActionItem: config.primaryActionItem ? (enhanceAppMenuItemWithRunAction({ From 0849402cb25adddc605d74ce24ca73e27d9e79c5 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 11:38:53 +0100 Subject: [PATCH 61/76] Fix type casting --- .../components/app_menu/app_menu_registry.ts | 35 ++++--- .../single_tab_view_with_app_menu.tsx | 5 +- .../components/tabs_view/use_app_menu_data.ts | 4 +- .../top_nav/app_menu_actions/get_alerts.tsx | 2 +- .../run_app_menu_action.test.tsx | 4 +- .../app_menu_actions/run_app_menu_action.tsx | 98 ++++++++++++------- .../top_nav/discover_topnav_menu.tsx | 4 +- .../top_nav/use_top_nav_links.test.tsx | 5 +- .../components/top_nav/use_top_nav_links.tsx | 31 +++--- 9 files changed, 102 insertions(+), 86 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts index c1ea2d14f1a9c..ee5646074f937 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/components/app_menu/app_menu_registry.ts @@ -8,12 +8,7 @@ */ import type { - AppMenuConfig, - AppMenuItemType, - AppMenuPrimaryActionItem, - AppMenuSecondaryActionItem, -} from '@kbn/core-chrome-app-menu-components'; -import type { + DiscoverAppMenuConfig, DiscoverAppMenuItemType, DiscoverAppMenuPopoverItem, DiscoverAppMenuPrimaryActionItem, @@ -45,10 +40,12 @@ export class AppMenuRegistry { */ public registerCustomPopoverItem(parentId: string, popoverItem: DiscoverAppMenuPopoverItem) { const parent = this.items.get(parentId); - this.items.set(parentId, { - ...parent, - items: [...(parent?.items || []), popoverItem], - } as DiscoverAppMenuItemType & { isCustom?: boolean }); + if (parent) { + this.items.set(parentId, { + ...parent, + items: [...(parent.items || []), popoverItem], + }); + } } /** @@ -90,17 +87,19 @@ export class AppMenuRegistry { */ public registerPopoverItem(parentId: string, popoverItem: DiscoverAppMenuPopoverItem) { const parent = this.items.get(parentId); - this.items.set(parentId, { - ...parent, - items: [...(parent?.items || []), popoverItem], - } as DiscoverAppMenuItemType); + if (parent) { + this.items.set(parentId, { + ...parent, + items: [...(parent.items || []), popoverItem], + }); + } } /** * Get the complete AppMenuConfig. * Items with registered popover items will have their items property populated. */ - public getAppMenuConfig(): AppMenuConfig { + public getAppMenuConfig(): DiscoverAppMenuConfig { const allItems = Array.from(this.items.values()); const regularItems = allItems.filter((item) => !item.isCustom); const customItems = allItems @@ -110,9 +109,9 @@ export class AppMenuRegistry { const cleanItems = [...regularItems, ...customItems].map(({ isCustom, ...item }) => item); return { - items: cleanItems as AppMenuItemType[], - primaryActionItem: this.primaryActionItem as AppMenuPrimaryActionItem | undefined, - secondaryActionItem: this.secondaryActionItem as AppMenuSecondaryActionItem | undefined, + items: cleanItems, + primaryActionItem: this.primaryActionItem, + secondaryActionItem: this.secondaryActionItem, }; } } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx index cbacfffcf7927..300d38a72f34b 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx @@ -8,7 +8,6 @@ */ import React from 'react'; -import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import { AppMenu } from '@kbn/core-chrome-app-menu'; import { SingleTabView, type SingleTabViewProps } from '.'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; @@ -20,9 +19,7 @@ export const SingleTabViewWithAppMenu = (props: SingleTabViewProps) => { return ( <> - {topNavMenuItems && ( - - )} + {topNavMenuItems && } ); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts index 74b00c24e5713..fd1c5ac7b83fb 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts @@ -37,7 +37,7 @@ interface UseAppMenuDataResult { shouldCollapseAppMenu: boolean; onResize: EuiResizeObserverProps['onResize']; getAdditionalTabMenuItems: UnifiedTabsProps['getAdditionalTabMenuItems']; - topNavMenuItems: AppMenuConfig; + topNavMenuItems: AppMenuConfig | undefined; } export const useAppMenuData = ({ currentDataView }: UseAppMenuDataParams): UseAppMenuDataResult => { @@ -106,6 +106,6 @@ export const useAppMenuData = ({ currentDataView }: UseAppMenuDataParams): UseAp shouldCollapseAppMenu, onResize, getAdditionalTabMenuItems, - topNavMenuItems: topNavMenuItems as AppMenuConfig, + topNavMenuItems, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx index b41cfc6e1d916..d806aebc0656a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.tsx @@ -38,7 +38,7 @@ const RuleFormFlyoutWithType = RuleFormFlyout; const CreateAlertFlyout: React.FC<{ discoverParams: AppMenuDiscoverParams; services: DiscoverServices; - onFinishAction?: () => void; + onFinishAction: () => void; stateContainer: DiscoverStateContainer; }> = ({ stateContainer, discoverParams, services, onFinishAction = () => {} }) => { const { diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx index 53d37f9714519..291246bd67578 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.test.tsx @@ -176,7 +176,7 @@ describe('run app menu actions', () => { const enhanced = enhanceAppMenuItemWithRunAction({ appMenuItem, services: discoverServiceMock, - }) as DiscoverAppMenuItemType; + }); expect(enhanced.items).toBeDefined(); expect(enhanced.items?.[0]).toBeDefined(); @@ -197,7 +197,7 @@ describe('run app menu actions', () => { const enhanced = enhanceAppMenuItemWithRunAction({ appMenuItem, services: discoverServiceMock, - }) as DiscoverAppMenuItemType; + }); expect(enhanced.id).toBe('action-1'); expect(enhanced.order).toBe(5); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx index 99c6d0e11f7a4..cbc049e2bfc8e 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/run_app_menu_action.tsx @@ -20,7 +20,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import type { AppMenuRunActionParams } from '@kbn/core-chrome-app-menu-components'; +import type { + AppMenuItemType, + AppMenuPopoverItem, + AppMenuPrimaryActionItem, + AppMenuRunActionParams, + AppMenuSecondaryActionItem, +} from '@kbn/core-chrome-app-menu-components'; import type { DiscoverAppMenuItemType, DiscoverAppMenuPopoverItem, @@ -112,47 +118,67 @@ export async function runAppMenuAction({ ReactDOM.render(element, container); } -export const enhanceAppMenuItemWithRunAction = ({ +/** + * Maps Discover-specific menu item types to their corresponding base AppMenu types. + */ +type EnhancedAppMenuItem = T extends DiscoverAppMenuItemType + ? AppMenuItemType + : T extends DiscoverAppMenuPrimaryActionItem + ? AppMenuPrimaryActionItem + : T extends DiscoverAppMenuSecondaryActionItem + ? AppMenuSecondaryActionItem + : T extends DiscoverAppMenuPopoverItem + ? AppMenuPopoverItem + : never; + +type DiscoverAppMenuItem = + | DiscoverAppMenuItemType + | DiscoverAppMenuPrimaryActionItem + | DiscoverAppMenuSecondaryActionItem + | DiscoverAppMenuPopoverItem; + +/** + * Transforms Discover-specific menu items into base AppMenu types by replacing + * the run action with one that wraps the Discover-specific behavior. + * This allows the items to be used with the core AppMenu component. + */ +export function enhanceAppMenuItemWithRunAction({ appMenuItem, services, parentTestId, }: { - appMenuItem: - | DiscoverAppMenuItemType - | DiscoverAppMenuPrimaryActionItem - | DiscoverAppMenuSecondaryActionItem; + appMenuItem: T; services: DiscoverServices; parentTestId?: string; -}): - | DiscoverAppMenuItemType - | DiscoverAppMenuPrimaryActionItem - | DiscoverAppMenuSecondaryActionItem => { - const itemWithItems = appMenuItem as DiscoverAppMenuPopoverItem; +}): EnhancedAppMenuItem { + const enhancedRun = appMenuItem.run + ? (params?: AppMenuRunActionParams) => { + if (params) { + runAppMenuAction({ + appMenuItem, + anchorElement: params.triggerElement, + services, + parentTestId, + }); + } + } + : undefined; + + const enhancedItems = + 'items' in appMenuItem && Array.isArray(appMenuItem.items) + ? appMenuItem.items.map( + (nestedItem): AppMenuPopoverItem => + enhanceAppMenuItemWithRunAction({ + appMenuItem: nestedItem, + services, + parentTestId: appMenuItem.testId || 'app-menu-overflow-button', + }) + ) + : undefined; return { ...appMenuItem, - items: itemWithItems.items?.map( - (nestedItem) => - enhanceAppMenuItemWithRunAction({ - appMenuItem: nestedItem as - | DiscoverAppMenuItemType - | DiscoverAppMenuPrimaryActionItem - | DiscoverAppMenuSecondaryActionItem, - services, - parentTestId: appMenuItem.testId || 'app-menu-overflow-button', - }) as DiscoverAppMenuPopoverItem - ), - run: appMenuItem.run - ? (params?: AppMenuRunActionParams) => { - if (params) { - runAppMenuAction({ - appMenuItem, - anchorElement: params.triggerElement, - services, - parentTestId, - }); - } - } - : undefined, - }; -}; + items: enhancedItems, + run: enhancedRun, + } as unknown as EnhancedAppMenuItem; +} diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx index 147ac13f45c83..f89a37d8108a7 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav_menu.tsx @@ -16,7 +16,7 @@ import React, { } from 'react'; import { BehaviorSubject } from 'rxjs'; import useUnmount from 'react-use/lib/useUnmount'; -import type { DiscoverAppMenuConfig } from '@kbn/discover-utils'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import type { useDiscoverTopNav } from './use_discover_topnav'; /** @@ -27,7 +27,7 @@ import type { useDiscoverTopNav } from './use_discover_topnav'; */ const createTopNavMenuContext = () => ({ - topNavMenu$: new BehaviorSubject(undefined), + topNavMenu$: new BehaviorSubject(undefined), }); type DiscoverTopNavMenuContext = ReturnType; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index c9915eb5c4f78..03441c1d30248 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -11,7 +11,6 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; -import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; import { BehaviorSubject } from 'rxjs'; import { useTopNavLinks } from './use_top_nav_links'; import type { DiscoverServices } from '../../../../build_services'; @@ -187,8 +186,8 @@ describe('useTopNavLinks', () => { expect(exportItem).toBeDefined(); expect(exportItem?.label).toBe('Export'); - expect((exportItem as DiscoverAppMenuItemType)?.items).toBeDefined(); - expect((exportItem as DiscoverAppMenuItemType)?.items?.length).toBeGreaterThan(0); + expect(exportItem?.items).toBeDefined(); + expect(exportItem?.items?.length).toBeGreaterThan(0); const shareItem = appMenuConfig.items?.find((item) => item.id === 'share'); expect(shareItem).toBeDefined(); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 1cf2e78d3d8e6..c4c1f5fe150bc 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -12,12 +12,8 @@ import { i18n } from '@kbn/i18n'; import type { DataView } from '@kbn/data-views-plugin/public'; import { METRIC_TYPE } from '@kbn/analytics'; import { ENABLE_ESQL, getInitialESQLQuery } from '@kbn/esql-utils'; -import type { - DiscoverAppMenuItemType, - DiscoverAppMenuConfig, - DiscoverAppMenuPrimaryActionItem, - DiscoverAppMenuSecondaryActionItem, -} from '@kbn/discover-utils'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; +import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; import { AppMenuRegistry, dismissFlyouts, DiscoverFlyouts } from '@kbn/discover-utils'; import { ESQL_TYPE } from '@kbn/data-view-utils'; import type { RuleTypeWithDescription } from '@kbn/alerts-ui-shared'; @@ -75,7 +71,7 @@ export const useTopNavLinks = ({ topNavCustomization: TopNavCustomization | undefined; hasShareIntegration: boolean; persistedDiscoverSession: DiscoverSession | undefined; -}): DiscoverAppMenuConfig => { +}): AppMenuConfig => { const intl = useI18n(); const dispatch = useInternalStateDispatch(); const currentDataView = useCurrentDataView(); @@ -337,28 +333,27 @@ export const useTopNavLinks = ({ transitionFromDataViewToESQL, ]); - return useMemo((): DiscoverAppMenuConfig => { + return useMemo((): AppMenuConfig => { const config = appMenuRegistry.getAppMenuConfig(); return { - items: config.items?.map( - (item) => - enhanceAppMenuItemWithRunAction({ - appMenuItem: item, - services, - }) as DiscoverAppMenuItemType + items: config.items?.map((item) => + enhanceAppMenuItemWithRunAction({ + appMenuItem: item, + services, + }) ), primaryActionItem: config.primaryActionItem - ? (enhanceAppMenuItemWithRunAction({ + ? enhanceAppMenuItemWithRunAction({ appMenuItem: config.primaryActionItem, services, - }) as DiscoverAppMenuPrimaryActionItem) + }) : undefined, secondaryActionItem: config.secondaryActionItem - ? (enhanceAppMenuItemWithRunAction({ + ? enhanceAppMenuItemWithRunAction({ appMenuItem: config.secondaryActionItem, services, - }) as DiscoverAppMenuSecondaryActionItem) + }) : undefined, }; }, [appMenuRegistry, services]); From 81351c9bbf0db402228b3c9b8149110d66836492 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 11:45:57 +0100 Subject: [PATCH 62/76] Improve getting export items --- .../top_nav/app_menu_actions/get_share.tsx | 101 ++++++++---------- 1 file changed, 43 insertions(+), 58 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index 6b40db94d6035..0c9bb5aba38b1 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -15,8 +15,8 @@ import type { TimeRange } from '@kbn/es-query'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import type { AppMenuItemType, AppMenuPopoverItem } from '@kbn/core-chrome-app-menu-components'; import type { ShowShareMenuOptions } from '@kbn/share-plugin/public'; -import type { IntlShape } from '@kbn/i18n-react'; import type { ShareActionIntents } from '@kbn/share-plugin/public/types'; +import type { IntlShape } from '@kbn/i18n-react'; import type { DiscoverStateContainer } from '../../../state_management/discover_state'; import type { DataTotalHitsMsg } from '../../../state_management/discover_data_state_container'; import { getSharingData, showPublicUrlSwitch } from '../../../../../utils/get_sharing_data'; @@ -163,70 +163,55 @@ const getExportItems = ( const exportIntegrations = services.share.availableIntegrations('search', 'export'); const exportDerivatives = services.share.availableIntegrations('search', 'exportDerivatives'); - const mapIntegrationToMetaData = (integrationId: string) => { - switch (integrationId) { - case 'csvReports': - return { - label: i18n.translate('discover.localMenu.export.csvLabel', { - defaultMessage: 'CSV', - }), - testId: 'exportMenuItem-CSV', - iconType: 'tableDensityNormal' as const, - order: 1, - }; - case 'scheduledReports': - return { - label: i18n.translate('discover.localMenu.export.scheduleExportLabel', { - defaultMessage: 'Schedule export', - }), - testId: 'exportMenuItem-scheduledReports', - iconType: 'calendar' as const, - order: 2, - }; - default: - return { - label: integrationId, - testId: `exportMenuItem-${integrationId}`, - order: Number.MAX_SAFE_INTEGER, - }; - } - }; + const hasCsvReports = exportIntegrations.some( + (item: ShareActionIntents) => + item.shareType === 'integration' && 'id' in item && item.id === 'csvReports' + ); + const hasScheduledReports = exportDerivatives.some( + (item: ShareActionIntents) => + item.shareType === 'integration' && 'id' in item && item.id === 'scheduledReports' + ); - const exportItems: AppMenuPopoverItem[] = exportIntegrations - .filter( - (item: ShareActionIntents): item is typeof item & { shareType: 'integration'; id: string } => - item.shareType === 'integration' - ) - .map((item: ShareActionIntents & { shareType: 'integration'; id: string }) => ({ - ...mapIntegrationToMetaData(item.id), - id: item.id, + const exportItems: AppMenuPopoverItem[] = []; + + if (hasCsvReports) { + exportItems.push({ + id: 'csvReports', + label: i18n.translate('discover.localMenu.export.csvLabel', { + defaultMessage: 'CSV', + }), + testId: 'exportMenuItem-CSV', + iconType: 'tableDensityNormal', + order: 1, run: async () => { const shareOptions = await buildShareOptions(buildShareOptionsParams); - const handler = await services.share?.getExportHandler(shareOptions, item.id, intl); + const handler = await services.share?.getExportHandler(shareOptions, 'csvReports', intl); await handler?.(); }, - })); + }); + } - const derivativeItems: AppMenuPopoverItem[] = exportDerivatives - .filter( - ( - item: ShareActionIntents - ): item is typeof item & { shareType: 'integration'; id: string; groupId: string } => - item.shareType === 'integration' && item.groupId === 'exportDerivatives' - ) - .map( - (item: ShareActionIntents & { shareType: 'integration'; id: string; groupId: string }) => ({ - ...mapIntegrationToMetaData(item.id), - id: item.id, - run: async () => { - const shareOptions = await buildShareOptions(buildShareOptionsParams); - const handler = await services.share?.getExportDerivativeHandler(shareOptions, item.id); - await handler?.(); - }, - }) - ); + if (hasScheduledReports) { + exportItems.push({ + id: 'scheduledReports', + label: i18n.translate('discover.localMenu.export.scheduleExportLabel', { + defaultMessage: 'Schedule export', + }), + testId: 'exportMenuItem-scheduledReports', + iconType: 'calendar', + order: 2, + run: async () => { + const shareOptions = await buildShareOptions(buildShareOptionsParams); + const handler = await services.share?.getExportDerivativeHandler( + shareOptions, + 'scheduledReports' + ); + await handler?.(); + }, + }); + } - return [...exportItems, ...derivativeItems]; + return exportItems; }; export const getShareAppMenuItem = ({ From 7a7f3d48b15083f99d5b5e8a0003601de3140bc9 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 11:57:01 +0100 Subject: [PATCH 63/76] Change selectDataViewMode to use active tab --- .../functional/page_objects/discover_page.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/platform/test/functional/page_objects/discover_page.ts b/src/platform/test/functional/page_objects/discover_page.ts index dbc08cca69c44..a9409ab717134 100644 --- a/src/platform/test/functional/page_objects/discover_page.ts +++ b/src/platform/test/functional/page_objects/discover_page.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import expect from '@kbn/expect'; +import expect from '@kbn/expect/expect'; import type { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; import { FtrService } from '../ftr_provider_context'; @@ -672,19 +672,23 @@ export class DiscoverPageObject extends FtrService { } public async selectDataViewMode() { - // Get tab elements and open the menu for the first tab + // Find the selected tab and open its menu const tabElements = await this.find.allByCssSelector('[data-test-subj^="unifiedTabs_tab_"]'); - if (tabElements.length > 0) { - const menuButton = await tabElements[0].findByCssSelector( - '[data-test-subj^="unifiedTabs_tabMenuBtn_"]' - ); - await menuButton.click(); - await this.retry.waitFor('tab menu to open', async () => { - return await this.testSubjects.exists('unifiedTabs_tabMenuItem_switchToClassic'); - }); - await this.testSubjects.click('unifiedTabs_tabMenuItem_switchToClassic'); - await this.header.waitUntilLoadingHasFinished(); - await this.waitUntilSearchingHasFinished(); + for (const tabElement of tabElements) { + const tabRoleElement = await tabElement.findByCssSelector('[role="tab"]'); + if ((await tabRoleElement.getAttribute('aria-selected')) === 'true') { + const menuButton = await tabElement.findByCssSelector( + '[data-test-subj^="unifiedTabs_tabMenuBtn_"]' + ); + await menuButton.click(); + await this.retry.waitFor('tab menu to open', async () => { + return await this.testSubjects.exists('unifiedTabs_tabMenuItem_switchToClassic'); + }); + await this.testSubjects.click('unifiedTabs_tabMenuItem_switchToClassic'); + await this.header.waitUntilLoadingHasFinished(); + await this.waitUntilSearchingHasFinished(); + return; + } } } From 89f3f01594b81005914f3daba7b16e62087c933b Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 12:06:43 +0100 Subject: [PATCH 64/76] Modify discover_topnav.test to use capabilities --- .../top_nav/discover_topnav.test.tsx | 164 +++++++----------- .../top_nav/use_top_nav_links.test.tsx | 2 +- 2 files changed, 64 insertions(+), 102 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx index 232313a1cb184..211bf5a8f9b1d 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx @@ -8,12 +8,12 @@ */ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import type { DiscoverTopNavProps } from './discover_topnav'; import { DiscoverTopNav } from './discover_topnav'; -import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { discoverServiceMock as mockDiscoverService } from '../../../../__mocks__/services'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; @@ -23,8 +23,8 @@ import { useDiscoverCustomization } from '../../../../customizations'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { internalStateActions } from '../../state_management/redux'; import { DiscoverTestProvider } from '../../../../__mocks__/test_provider'; -import { DiscoverTopNavMenuProvider } from './discover_topnav_menu'; -import { useDiscoverTopNav } from './use_discover_topnav'; +import { DiscoverTopNavMenuProvider, discoverTopNavMenuContext } from './discover_topnav_menu'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; jest.mock('@kbn/kibana-react-plugin/public', () => ({ ...jest.requireActual('@kbn/kibana-react-plugin/public'), @@ -54,19 +54,12 @@ const mockSearchBarCustomizationWithHiddenDataViewPicker: SearchBarCustomization }; let mockUseCustomizations = false; -let mockAppMenuConfig: AppMenuConfig = { - items: [], -}; jest.mock('../../../../customizations', () => ({ ...jest.requireActual('../../../../customizations'), useDiscoverCustomization: jest.fn(), })); -jest.mock('./use_discover_topnav', () => ({ - useDiscoverTopNav: jest.fn(), -})); - const mockDefaultCapabilities = { discover_v2: { save: true }, } as unknown as typeof mockDiscoverService.capabilities; @@ -93,6 +86,16 @@ function getProps( }; } +// Helper component to capture the topNavMenu from context +let capturedTopNavMenu: AppMenuConfig | undefined; +const TopNavMenuCapture = () => { + const { topNavMenu$ } = useContext(discoverTopNavMenuContext); + topNavMenu$.subscribe((menu) => { + capturedTopNavMenu = menu; + }); + return null; +}; + const mockUseKibana = useKibana as jest.Mock; const getTestComponent = (props: DiscoverTopNavProps) => mountWithIntl( @@ -102,6 +105,7 @@ const getTestComponent = (props: DiscoverTopNavProps) => runtimeState={{ currentDataView: dataViewMock, adHocDataViews: [] }} > + @@ -111,6 +115,7 @@ describe('Discover topnav component', () => { beforeEach(() => { mockTopNavCustomization.defaultMenu = undefined; mockUseCustomizations = false; + capturedTopNavMenu = undefined; jest.clearAllMocks(); (useDiscoverCustomization as jest.Mock).mockImplementation((id: DiscoverCustomizationId) => { @@ -131,55 +136,32 @@ describe('Discover topnav component', () => { mockUseKibana.mockReturnValue({ services: mockDiscoverService, }); - - (useDiscoverTopNav as jest.Mock).mockImplementation(() => ({ - topNavMenu: mockAppMenuConfig, - topNavBadges: [], - })); }); - test('generated config of AppMenuConfig is correct when discover save permissions are assigned', () => { - mockAppMenuConfig = { - items: [ - { id: 'inspect', label: 'Inspect', iconType: 'inspect', order: 1, run: jest.fn() }, - { id: 'new', label: 'New', iconType: 'plusInCircle', order: 2, run: jest.fn() }, - { id: 'open', label: 'Open', iconType: 'folderOpen', order: 3, run: jest.fn() }, - ], - primaryActionItem: { - id: 'save', - label: 'Save', - iconType: 'save', - run: jest.fn(), - }, - }; + test('generated config of AppMenuConfig is correct when discover save permissions are assigned', async () => { const props = getProps({ capabilities: { discover_v2: { save: true } } }); - getTestComponent(props); + await act(async () => { + getTestComponent(props); + }); - const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; - const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + const itemIds = capturedTopNavMenu?.items?.map((item) => item.id) || []; expect(itemIds).toEqual(['inspect', 'new', 'open']); - expect(topNavMenu.primaryActionItem?.id).toBe('save'); + expect(capturedTopNavMenu?.primaryActionItem?.id).toBe('save'); }); - test('generated config of AppMenuConfig is correct when no discover save permissions are assigned', () => { - mockAppMenuConfig = { - items: [ - { id: 'inspect', label: 'Inspect', iconType: 'inspect', order: 1, run: jest.fn() }, - { id: 'new', label: 'New', iconType: 'plusInCircle', order: 2, run: jest.fn() }, - { id: 'open', label: 'Open', iconType: 'folderOpen', order: 3, run: jest.fn() }, - ], - }; + test('generated config of AppMenuConfig is correct when no discover save permissions are assigned', async () => { const props = getProps({ capabilities: { discover_v2: { save: false } } }); - getTestComponent(props); + await act(async () => { + getTestComponent(props); + }); - const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; - const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + const itemIds = capturedTopNavMenu?.items?.map((item) => item.id) || []; expect(itemIds).toEqual(['inspect', 'new', 'open']); - expect(topNavMenu.primaryActionItem).toBeUndefined(); + expect(capturedTopNavMenu?.primaryActionItem).toBeUndefined(); }); describe('top nav customization', () => { - it('should allow disabling default menu items', () => { + it('should allow disabling default menu items', async () => { mockUseCustomizations = true; mockTopNavCustomization.defaultMenu = { newItem: { disabled: true }, @@ -189,14 +171,12 @@ describe('Discover topnav component', () => { inspectItem: { disabled: true }, saveItem: { disabled: true }, }; - mockAppMenuConfig = { - items: [], - }; const props = getProps(); - getTestComponent(props); + await act(async () => { + getTestComponent(props); + }); - const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; - const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + const itemIds = capturedTopNavMenu?.items?.map((item) => item.id) || []; expect(itemIds).toEqual([]); }); @@ -218,31 +198,18 @@ describe('Discover topnav component', () => { )).mockImplementation(() => []); }); - it('will include share menu item if the share service is available', () => { - mockAppMenuConfig = { - items: [ - { id: 'inspect', label: 'Inspect', iconType: 'inspect', order: 1, run: jest.fn() }, - { id: 'new', label: 'New', iconType: 'plusInCircle', order: 2, run: jest.fn() }, - { id: 'open', label: 'Open', iconType: 'folderOpen', order: 3, run: jest.fn() }, - { id: 'share', label: 'Share', iconType: 'share', order: 4, run: jest.fn() }, - ], - primaryActionItem: { - id: 'save', - label: 'Save', - iconType: 'save', - run: jest.fn(), - }, - }; + it('will include share menu item if the share service is available', async () => { const props = getProps(); - getTestComponent(props); + await act(async () => { + getTestComponent(props); + }); - const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; - const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + const itemIds = capturedTopNavMenu?.items?.map((item) => item.id) || []; expect(itemIds).toEqual(['inspect', 'new', 'open', 'share']); - expect(topNavMenu.primaryActionItem?.id).toBe('save'); + expect(capturedTopNavMenu?.primaryActionItem?.id).toBe('save'); }); - it('will include export menu item if there are export integrations available', () => { + it('will include export menu item if there are export integrations available', async () => { availableIntegrationsSpy.mockImplementation((_objectType, groupId) => { if (groupId === 'export') { return [ @@ -258,34 +225,20 @@ describe('Discover topnav component', () => { return []; }); - mockAppMenuConfig = { - items: [ - { id: 'inspect', label: 'Inspect', iconType: 'inspect', order: 1, run: jest.fn() }, - { id: 'new', label: 'New', iconType: 'plusInCircle', order: 2, run: jest.fn() }, - { id: 'open', label: 'Open', iconType: 'folderOpen', order: 3, run: jest.fn() }, - { id: 'export', label: 'Export', iconType: 'exportAction', order: 4, run: jest.fn() }, - { id: 'share', label: 'Share', iconType: 'share', order: 5, run: jest.fn() }, - ], - primaryActionItem: { - id: 'save', - label: 'Save', - iconType: 'save', - run: jest.fn(), - }, - }; const props = getProps(); - getTestComponent(props); + await act(async () => { + getTestComponent(props); + }); - const { topNavMenu } = (useDiscoverTopNav as jest.Mock).mock.results[0].value; - const itemIds = topNavMenu.items?.map((item: { id: string }) => item.id) || []; + const itemIds = capturedTopNavMenu?.items?.map((item) => item.id) || []; expect(itemIds).toEqual(['inspect', 'new', 'open', 'export', 'share']); - expect(topNavMenu.primaryActionItem?.id).toBe('save'); + expect(capturedTopNavMenu?.primaryActionItem?.id).toBe('save'); }); }); }); describe('search bar customization', () => { - it('should render custom Search Bar', () => { + it('should render custom Search Bar', async () => { (useDiscoverCustomization as jest.Mock).mockImplementation((id: DiscoverCustomizationId) => { if (id === 'search_bar') { return mockSearchBarCustomizationWithCustomSearchBar; @@ -293,17 +246,23 @@ describe('Discover topnav component', () => { }); const props = getProps(); - const component = getTestComponent(props); + let component: ReturnType; + await act(async () => { + component = getTestComponent(props); + }); - expect(component.find({ 'data-test-subj': 'custom-search-bar' })).toHaveLength(1); + expect(component!.find({ 'data-test-subj': 'custom-search-bar' })).toHaveLength(1); }); - it('should render CustomDataViewPicker', () => { + it('should render CustomDataViewPicker', async () => { mockUseCustomizations = true; const props = getProps(); - const component = getTestComponent(props); + let component: ReturnType; + await act(async () => { + component = getTestComponent(props); + }); - const topNav = component + const topNav = component! .find(mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu) .at(0); expect(topNav.prop('dataViewPickerComponentProps')).toBeUndefined(); @@ -313,7 +272,7 @@ describe('Discover topnav component', () => { expect(dataViewPickerOverride.length).toBe(1); }); - it('should not render the dataView picker when hideDataViewPicker is true', () => { + it('should not render the dataView picker when hideDataViewPicker is true', async () => { (useDiscoverCustomization as jest.Mock).mockImplementation((id: DiscoverCustomizationId) => { if (id === 'search_bar') { return mockSearchBarCustomizationWithHiddenDataViewPicker; @@ -321,9 +280,12 @@ describe('Discover topnav component', () => { }); const props = getProps(); - const component = getTestComponent(props); + let component: ReturnType; + await act(async () => { + component = getTestComponent(props); + }); - const topNav = component + const topNav = component! .find(mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu) .at(0); expect(topNav.prop('dataViewPickerComponentProps')).toBeUndefined(); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index 03441c1d30248..e654016df90f2 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -153,7 +153,7 @@ describe('useTopNavLinks', () => { if (groupId === 'export') { return [ { - id: 'export', + id: 'csvReports', shareType: 'integration' as const, groupId: 'export', config: () => Promise.resolve({}), From 737bd617f62b99f045bea0fa90e1c54956ed34c3 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 12:15:49 +0100 Subject: [PATCH 65/76] Add splitbutton tests --- .../top_nav/use_top_nav_links.test.tsx | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx index e654016df90f2..4a533aa0e3153 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.test.tsx @@ -223,4 +223,83 @@ describe('useTopNavLinks', () => { expect(backgroundSearchItem).toBeUndefined(); }); }); + + describe('save button with unsaved changes', () => { + it('should show notification indicator when there are unsaved changes', () => { + const appMenuConfig = setup({ hasUnsavedChanges: true }); + + expect(appMenuConfig.primaryActionItem).toBeDefined(); + expect(appMenuConfig.primaryActionItem?.id).toBe('save'); + expect(appMenuConfig.primaryActionItem?.splitButtonProps?.showNotificationIndicator).toBe( + true + ); + expect( + appMenuConfig.primaryActionItem?.splitButtonProps?.notifcationIndicatorTooltipContent + ).toBe('You have unsaved changes'); + }); + + it('should NOT show notification indicator when there are no unsaved changes', () => { + const appMenuConfig = setup({ hasUnsavedChanges: false }); + + expect(appMenuConfig.primaryActionItem).toBeDefined(); + expect(appMenuConfig.primaryActionItem?.id).toBe('save'); + expect(appMenuConfig.primaryActionItem?.splitButtonProps?.showNotificationIndicator).toBe( + false + ); + expect( + appMenuConfig.primaryActionItem?.splitButtonProps?.notifcationIndicatorTooltipContent + ).toBeUndefined(); + }); + + it('should include Save as and Reset changes options in split button menu', () => { + const appMenuConfig = setup({ hasUnsavedChanges: true }); + + expect(appMenuConfig.primaryActionItem?.splitButtonProps?.items).toBeDefined(); + const itemIds = appMenuConfig.primaryActionItem?.splitButtonProps?.items?.map( + (item) => item.id + ); + expect(itemIds).toContain('saveAs'); + expect(itemIds).toContain('resetChanges'); + }); + + it('should have correct labels for split button menu items', () => { + const appMenuConfig = setup({ hasUnsavedChanges: true }); + + const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; + const saveAsItem = items?.find((item) => item.id === 'saveAs'); + const resetChangesItem = items?.find((item) => item.id === 'resetChanges'); + + expect(saveAsItem?.label).toBe('Save as'); + expect(resetChangesItem?.label).toBe('Reset changes'); + }); + + it('should have run functions defined for split button menu items', () => { + const appMenuConfig = setup({ hasUnsavedChanges: true }); + + const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; + const saveAsItem = items?.find((item) => item.id === 'saveAs'); + const resetChangesItem = items?.find((item) => item.id === 'resetChanges'); + + expect(saveAsItem?.run).toBeDefined(); + expect(resetChangesItem?.run).toBeDefined(); + }); + + it('should disable reset changes button when there are no unsaved changes', () => { + const appMenuConfig = setup({ hasUnsavedChanges: false }); + + const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; + const resetChangesItem = items?.find((item) => item.id === 'resetChanges'); + + expect(resetChangesItem?.disableButton).toBe(true); + }); + + it('should enable reset changes button when there are unsaved changes', () => { + const appMenuConfig = setup({ hasUnsavedChanges: true }); + + const items = appMenuConfig.primaryActionItem?.splitButtonProps?.items; + const resetChangesItem = items?.find((item) => item.id === 'resetChanges'); + + expect(resetChangesItem?.disableButton).toBe(false); + }); + }); }); From 4dbce7d8fcf6700b1990b6bf1d2491155f3c498b Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:43:28 +0000 Subject: [PATCH 66/76] Changes from node scripts/eslint_all_files --no-cache --fix --- src/platform/test/functional/page_objects/discover_page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/test/functional/page_objects/discover_page.ts b/src/platform/test/functional/page_objects/discover_page.ts index a9409ab717134..2efedfe7fc9cc 100644 --- a/src/platform/test/functional/page_objects/discover_page.ts +++ b/src/platform/test/functional/page_objects/discover_page.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import type { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; import { FtrService } from '../ftr_provider_context'; From 996033edee93a2a2285b726f669b8f9507a96f9f Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 15:31:46 +0100 Subject: [PATCH 67/76] Fix new button --- .../src/components/app_menu_action_button.tsx | 2 +- .../src/components/app_menu_item.tsx | 2 +- .../src/types.ts | 24 +++++++++---------- .../src/utils.test.tsx | 8 ------- .../src/utils.tsx | 2 +- .../app_menu_actions/get_new_search.tsx | 7 +++++- .../components/top_nav/use_top_nav_links.tsx | 24 +++++++++++++------ 7 files changed, 38 insertions(+), 31 deletions(-) diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx index f4748fe2bc5f1..40b09e4c55c75 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx @@ -101,7 +101,7 @@ export const AppMenuActionButton = (props: AppMenuActionButtonProps) => { }; const commonProps = { - onClick: href ? undefined : handleClick, + onClick: handleClick, id: htmlId, 'data-test-subj': testId || `app-menu-action-button-${id}`, iconType, diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx index ea08f99b0fcec..1a08121d4ae24 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx @@ -73,7 +73,7 @@ export const AppMenuItem = ({ const buttonComponent = ( { expect(result.onClick).toBeDefined(); }); - it('should not set onClick when href is provided', () => { - const item = { ...baseItem, href: 'http://example.com' }; - const result = mapAppMenuItemToPanelItem(item); - - expect(result.onClick).toBeUndefined(); - expect(result.href).toBe('http://example.com'); - }); - it('should not set onClick when childPanelId is provided', () => { const result = mapAppMenuItemToPanelItem(baseItem, 1); diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx index c829ebe611510..c4d531efbf77f 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx @@ -136,7 +136,7 @@ export const mapAppMenuItemToPanelItem = ( key: item.id, name: upperFirst(item.label), icon: item?.iconType, - onClick: item?.href || childPanelId !== undefined ? undefined : handleClick, + onClick: childPanelId !== undefined ? undefined : handleClick, href: item?.href, target: item?.href ? item?.target : undefined, disabled: isDisabled(item?.disableButton), diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx index c255e0bcc0235..88b5fa575ce6a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_new_search.tsx @@ -12,9 +12,11 @@ import { AppMenuActionId } from '@kbn/discover-utils'; import { i18n } from '@kbn/i18n'; export const getNewSearchAppMenuItem = ({ + onNewSearch, newSearchUrl, }: { - newSearchUrl: string; + onNewSearch: () => void; + newSearchUrl?: string; }): DiscoverAppMenuItemType => { return { id: AppMenuActionId.new, @@ -25,5 +27,8 @@ export const getNewSearchAppMenuItem = ({ iconType: 'plusInCircle', testId: 'discoverNewButton', href: newSearchUrl, + run: () => { + onNewSearch(); + }, }; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index c4c1f5fe150bc..2f8d721e64f08 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -16,11 +16,14 @@ import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import type { DiscoverAppMenuItemType } from '@kbn/discover-utils'; import { AppMenuRegistry, dismissFlyouts, DiscoverFlyouts } from '@kbn/discover-utils'; import { ESQL_TYPE } from '@kbn/data-view-utils'; +import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics'; import type { RuleTypeWithDescription } from '@kbn/alerts-ui-shared'; import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared'; import useObservable from 'react-use/lib/useObservable'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import { useI18n } from '@kbn/i18n-react'; +import type { DiscoverAppLocatorParams } from '../../../../../common'; +import { createDataViewDataSource } from '../../../../../common/data_sources'; import type { DiscoverServices } from '../../../../build_services'; import type { DiscoverStateContainer } from '../../state_management/discover_state'; import type { AppMenuDiscoverParams } from './app_menu_actions'; @@ -155,14 +158,21 @@ export const useTopNavLinks = ({ isEsqlMode && currentDataView.type === ESQL_TYPE ? { query: { esql: getInitialESQLQuery(currentDataView, true) } } : undefined; - const locatorParams = - defaultEsqlState ?? - (currentDataView.isPersisted() - ? { dataViewId: currentDataView.id } - : { dataViewSpec: currentDataView.toMinimalSpec() }); - const newSearchUrl = services.locator.getRedirectUrl(locatorParams); + const locatorParams: DiscoverAppLocatorParams = defaultEsqlState + ? defaultEsqlState + : currentDataView.isPersisted() + ? { dataViewId: currentDataView.id } + : { dataViewSpec: currentDataView.toMinimalSpec() }; const newSearchMenuItem = getNewSearchAppMenuItem({ - newSearchUrl, + newSearchUrl: services.locator.getRedirectUrl(locatorParams), + onNewSearch: () => { + const defaultState: DiscoverAppState = defaultEsqlState ?? { + dataSource: currentDataView.id + ? createDataViewDataSource({ dataViewId: currentDataView.id }) + : undefined, + }; + services.application.navigateToApp(DISCOVER_APP_ID, { state: { defaultState } }); + }, }); items.push(newSearchMenuItem); } From cd4b62c8b225e2fd5d7b5165ce97fa1b0832b916 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 15:39:15 +0100 Subject: [PATCH 68/76] Fix embedded conflick resolution gone wrong --- .../components/top_nav/use_top_nav_links.tsx | 139 +++++++++++------- 1 file changed, 84 insertions(+), 55 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 2f8d721e64f08..178e6ad52ebd3 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -253,73 +253,102 @@ export const useTopNavLinks = ({ } if (services.capabilities.discover_v2.save && !defaultMenu?.saveItem?.disabled) { + const isEmbeddedEditor = services.embeddableEditor.isEmbeddedEditor(); + + // In embedded editor mode, add "Cancel" button + if (isEmbeddedEditor) { + newAppMenuRegistry.registerItems([ + { + id: 'cancel', + order: 100, + label: i18n.translate('discover.localMenu.cancelTitle', { + defaultMessage: 'Cancel', + }), + testId: 'discoverCancelButton', + run: services.embeddableEditor.transferBackToEditor, + }, + ]); + } + newAppMenuRegistry.setPrimaryActionItem({ id: 'save', - label: i18n.translate('discover.localMenu.saveTitle', { - defaultMessage: 'Save', - }), + label: isEmbeddedEditor + ? i18n.translate('discover.localMenu.saveAndReturnTitle', { + defaultMessage: 'Save and return', + }) + : i18n.translate('discover.localMenu.saveTitle', { + defaultMessage: 'Save', + }), testId: 'discoverSaveButton', - iconType: 'save', + iconType: isEmbeddedEditor ? 'checkInCircleFilled' : 'save', run: async () => { await onSaveDiscoverSession({ services, state, + onSaveCb: isEmbeddedEditor ? services.embeddableEditor.transferBackToEditor : undefined, }); }, - popoverWidth: 150, - popoverTestId: 'discoverSaveButtonPopover', - splitButtonProps: { - showNotificationIndicator: - hasUnsavedChanges && !services.embeddableEditor.isEmbeddedEditor(), - notifcationIndicatorTooltipContent: hasUnsavedChanges - ? i18n.translate('discover.localMenu.unsavedChangesTooltip', { - defaultMessage: 'You have unsaved changes', - }) - : undefined, - secondaryButtonIcon: 'arrowDown', - secondaryButtonAriaLabel: i18n.translate('discover.localMenu.saveOptionsAriaLabel', { - defaultMessage: 'Save options', - }), - items: [ - { - run: async () => { - await onSaveDiscoverSession({ - initialCopyOnSave: true, - services, - state, - }); - }, - id: 'saveAs', - order: 1, - label: i18n.translate('discover.localMenu.saveAsTitle', { - defaultMessage: 'Save as', - }), - iconType: 'save', - testId: 'interactiveSaveMenuItem', - }, - { - run: async () => { - dismissFlyouts([DiscoverFlyouts.lensEdit]); + // Only show split button options when not in embedded editor mode + ...(isEmbeddedEditor + ? {} + : { + popoverWidth: 150, + popoverTestId: 'discoverSaveButtonPopover', + splitButtonProps: { + showNotificationIndicator: hasUnsavedChanges, + notifcationIndicatorTooltipContent: hasUnsavedChanges + ? i18n.translate('discover.localMenu.unsavedChangesTooltip', { + defaultMessage: 'You have unsaved changes', + }) + : undefined, + secondaryButtonIcon: 'arrowDown', + secondaryButtonAriaLabel: i18n.translate( + 'discover.localMenu.saveOptionsAriaLabel', + { + defaultMessage: 'Save options', + } + ), + items: [ + { + run: async () => { + await onSaveDiscoverSession({ + initialCopyOnSave: true, + services, + state, + }); + }, + id: 'saveAs', + order: 1, + label: i18n.translate('discover.localMenu.saveAsTitle', { + defaultMessage: 'Save as', + }), + iconType: 'save', + testId: 'interactiveSaveMenuItem', + }, + { + run: async () => { + dismissFlyouts([DiscoverFlyouts.lensEdit]); - const internalState = state.internalState.getState(); + const internalState = state.internalState.getState(); - if (internalState.persistedDiscoverSession) { - await state.internalState - .dispatch(internalStateActions.resetDiscoverSession()) - .unwrap(); - } + if (internalState.persistedDiscoverSession) { + await state.internalState + .dispatch(internalStateActions.resetDiscoverSession()) + .unwrap(); + } + }, + id: 'resetChanges', + order: 2, + label: i18n.translate('discover.localMenu.resetChangesTitle', { + defaultMessage: 'Reset changes', + }), + iconType: 'editorUndo', + testId: 'revertUnsavedChangesButton', + disableButton: !hasUnsavedChanges, + }, + ], }, - id: 'resetChanges', - order: 2, - label: i18n.translate('discover.localMenu.resetChangesTitle', { - defaultMessage: 'Reset changes', - }), - iconType: 'editorUndo', - testId: 'revertUnsavedChangesButton', - disableButton: !hasUnsavedChanges, - }, - ], - }, + }), }); } From 4b963df1609d2a6256333341a263218c3ac53675 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 15:42:32 +0100 Subject: [PATCH 69/76] Add missing icon --- .../application/main/components/top_nav/use_top_nav_links.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 178e6ad52ebd3..ce412a10babbd 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -266,6 +266,7 @@ export const useTopNavLinks = ({ }), testId: 'discoverCancelButton', run: services.embeddableEditor.transferBackToEditor, + iconType: 'editorUndo', }, ]); } From 8679fd2fa1a5b5cb1da336b52c2a30463ff1bc8d Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 17:21:48 +0100 Subject: [PATCH 70/76] Fix onClick behavior in app menu --- .../app-menu/core-chrome-app-menu-components/moon.yml | 1 + .../src/components/app_menu_action_button.tsx | 6 +++++- .../src/components/app_menu_item.tsx | 6 +++++- .../core-chrome-app-menu-components/src/utils.tsx | 9 ++++++++- .../core-chrome-app-menu-components/tsconfig.json | 1 + 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/moon.yml b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/moon.yml index 85de7e84bdaa0..e840fc3113d56 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/moon.yml +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/moon.yml @@ -20,6 +20,7 @@ project: dependsOn: - '@kbn/split-button' - '@kbn/i18n' + - '@kbn/router-utils' tags: - shared-browser - package diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx index 40b09e4c55c75..f9779df713626 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx @@ -13,6 +13,7 @@ import { upperFirst } from 'lodash'; import type { EuiButtonColor, PopoverAnchorPosition } from '@elastic/eui'; import { EuiButton, EuiHideFor, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; +import { getRouterLinkProps } from '@kbn/router-utils'; import { APP_MENU_NOTIFICATION_INDICATOR_LEFT, APP_MENU_NOTIFICATION_INDICATOR_TOP, @@ -100,8 +101,11 @@ export const AppMenuActionButton = (props: AppMenuActionButtonProps) => { splitButtonRun?.({ triggerElement: event.currentTarget }); }; + const routerLinkProps = + href && run ? getRouterLinkProps({ href, onClick: handleClick }) : { onClick: handleClick }; + const commonProps = { - onClick: handleClick, + ...routerLinkProps, id: htmlId, 'data-test-subj': testId || `app-menu-action-button-${id}`, iconType, diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx index 1a08121d4ae24..22c40a28d3d0d 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_item.tsx @@ -11,6 +11,7 @@ import React, { type MouseEvent } from 'react'; import { EuiHeaderLink, EuiHideFor, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { upperFirst } from 'lodash'; import { css } from '@emotion/react'; +import { getRouterLinkProps } from '@kbn/router-utils'; import { getIsSelectedColor, getTooltip, isDisabled } from '../utils'; import { AppMenuPopover } from './app_menu_popover'; import type { AppMenuItemType } from '../types'; @@ -60,6 +61,9 @@ export const AppMenuItem = ({ run?.({ triggerElement: event.currentTarget }); }; + const routerLinkProps = + href && run ? getRouterLinkProps({ href, onClick: handleClick }) : { onClick: handleClick }; + const buttonCss = css` background-color: ${isPopoverOpen ? getIsSelectedColor({ @@ -73,7 +77,6 @@ export const AppMenuItem = ({ const buttonComponent = ( {itemText} diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx index c4d531efbf77f..a7e887daccfb7 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/utils.tsx @@ -15,6 +15,7 @@ import { type EuiContextMenuPanelDescriptor, type EuiContextMenuPanelItemDescriptor, } from '@elastic/eui'; +import { getRouterLinkProps } from '@kbn/router-utils'; import { AppMenuPopoverActionButtons } from './components/app_menu_popover_action_buttons'; import type { AppMenuConfig, @@ -132,11 +133,17 @@ export const mapAppMenuItemToPanelItem = ( } }; + const hasClickHandler = childPanelId === undefined; + const routerLinkProps = + item?.href && item?.run && hasClickHandler + ? getRouterLinkProps({ href: item.href, onClick: handleClick }) + : { onClick: hasClickHandler ? handleClick : undefined }; + return { key: item.id, name: upperFirst(item.label), icon: item?.iconType, - onClick: childPanelId !== undefined ? undefined : handleClick, + ...routerLinkProps, href: item?.href, target: item?.href ? item?.target : undefined, disabled: isDisabled(item?.disableButton), diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/tsconfig.json b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/tsconfig.json index 2ac2f49ed823c..6e39f09d3522d 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/tsconfig.json +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/tsconfig.json @@ -18,5 +18,6 @@ "kbn_references": [ "@kbn/split-button", "@kbn/i18n", + "@kbn/router-utils" ] } From 43c56c4b836b95cc2e42697d9577cc143bd27a02 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 22 Jan 2026 17:48:02 +0100 Subject: [PATCH 71/76] Test fixes --- .../app_menu_actions/get_alerts.test.tsx | 16 ++++++++++------ .../_unsaved_changes_notification_indicator.ts | 18 +++--------------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx index b193d8fb8dd84..b792f88bdd9ab 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_alerts.test.tsx @@ -139,9 +139,11 @@ describe('getAlertsAppMenuItem', () => { (discoverServiceMock.application.getUrlForApp as jest.Mock).mockImplementation( (appId: string) => `/app/${appId}` ); - const component = mount(); - const manageButton = findTestSubject(component, 'discoverManageAlertsButton'); - expect(manageButton.prop('href')).toContain('/app/rules'); + const alertsMenuItem = getAlertsMenuItem(); + const manageAlertsItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverManageAlertsButton' + ); + expect(manageAlertsItem?.href).toBe('/app/rules'); }); it('should link to the management page when rules app is not registered', () => { @@ -149,9 +151,11 @@ describe('getAlertsAppMenuItem', () => { (discoverServiceMock.application.getUrlForApp as jest.Mock).mockImplementation( (appId: string) => `/app/${appId}` ); - const component = mount(); - const manageButton = findTestSubject(component, 'discoverManageAlertsButton'); - expect(manageButton.prop('href')).toContain( + const alertsMenuItem = getAlertsMenuItem(); + const manageAlertsItem = alertsMenuItem.items?.find( + (item) => item.testId === 'discoverManageAlertsButton' + ); + expect(manageAlertsItem?.href).toBe( '/app/management/insightsAndAlerting/triggersActions/rules' ); }); diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts index 59e235bac8680..a7ae0a204501e 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/group6/_unsaved_changes_notification_indicator.ts @@ -16,8 +16,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const dataGrid = getService('dataGrid'); const filterBar = getService('filterBar'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects([ 'common', 'svlCommonPage', @@ -32,16 +30,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { defaultIndex: 'logstash-*', }; - const loadSavedSearchWithRetry = async (searchName: string) => { - await PageObjects.discover.openLoadSavedSearchPanel(); - const searchItemTestSubj = `savedObjectTitle${searchName.split(' ').join('-')}`; - await retry.waitFor(`saved search "${searchName}" to appear in the list`, async () => { - return await testSubjects.exists(searchItemTestSubj); - }); - await testSubjects.click(searchItemTestSubj); - await PageObjects.header.waitUntilLoadingHasFinished(); - }; - describe('discover unsaved changes notification indicator', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); @@ -104,7 +92,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should not show a notification indicator after loading a saved search, only after changes', async () => { - await loadSavedSearchWithRetry(SAVED_SEARCH_NAME); + await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.discover.waitUntilSearchingHasFinished(); @@ -118,7 +106,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should allow to revert changes', async () => { - await loadSavedSearchWithRetry(SAVED_SEARCH_NAME); + await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.discover.ensureNoUnsavedChangesIndicator(); @@ -163,7 +151,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should hide the notification indicator once user manually reverts changes', async () => { - await loadSavedSearchWithRetry(SAVED_SEARCH_NAME); + await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); await PageObjects.discover.waitUntilTabIsLoaded(); await PageObjects.discover.ensureNoUnsavedChangesIndicator(); From dadbe1a35f1a574d6cd6751fdbf12b14c8c365d3 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 22 Jan 2026 19:36:14 -0400 Subject: [PATCH 72/76] Make sure the app menu renders when it should --- .../main/components/single_tab_view/index.ts | 1 + .../single_tab_view/single_tab_view.tsx | 2 +- .../components/tabs_view/hide_tabs_bar.tsx | 27 ++++++++++++++++--- .../components/tabs_view/use_app_menu_data.ts | 8 +++--- .../application/main/discover_main_route.tsx | 27 ++++++++++++++----- 5 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/index.ts b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/index.ts index 48793e2eb3f33..a9770722e9cc6 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/index.ts @@ -11,3 +11,4 @@ export { BrandedLoadingIndicator } from './branded_loading_indicator'; export { NoDataPage } from './no_data_page'; export { InitializationError } from './initialization_error'; export { SingleTabView, type SingleTabViewProps } from './single_tab_view'; +export { SingleTabViewWithAppMenu } from './single_tab_view_with_app_menu'; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view.tsx index a77951a2d5c4f..b058051f5e275 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view.tsx @@ -152,7 +152,7 @@ export const SingleTabView = ({ if (currentTabInitializationState.initializationStatus === TabInitializationStatus.NoData) { return ( - + { diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx index b2181bba14a26..7cc0db552e265 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx @@ -8,12 +8,21 @@ */ import type { FC, ReactNode } from 'react'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; +import { AppMenu } from '@kbn/core-chrome-app-menu'; import { internalStateActions, useInternalStateDispatch } from '../../state_management/redux'; import { TabsBarVisibility } from '../../state_management/redux/types'; +import { useDiscoverServices } from '../../../../hooks/use_discover_services'; +import { useTopNavMenuItems } from '../top_nav/use_top_nav_menu_items'; +import type { DiscoverCustomizationContext } from '../../../../customizations'; -export const HideTabsBar: FC<{ children: ReactNode }> = ({ children }) => { +export const HideTabsBar: FC<{ + customizationContext: DiscoverCustomizationContext; + children: ReactNode; +}> = ({ customizationContext, children }) => { const dispatch = useInternalStateDispatch(); + const { chrome } = useDiscoverServices(); + const topNavMenuItems = useTopNavMenuItems(); useEffect(() => { dispatch(internalStateActions.setTabsBarVisibility(TabsBarVisibility.hidden)); @@ -22,5 +31,17 @@ export const HideTabsBar: FC<{ children: ReactNode }> = ({ children }) => { }; }, [dispatch]); - return children; + return ( + <> + { + /** + * The tabs bar renders the app menu, but it still needs to be shown when tabs are hidden + */ + customizationContext.displayMode === 'standalone' && topNavMenuItems && ( + + ) + } + {children} + + ); }; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts index fd1c5ac7b83fb..12afe5b7c64c4 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts @@ -7,16 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useCallback, useContext, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { EuiResizeObserverProps } from '@elastic/eui'; import type { UnifiedTabsProps, TabMenuItem } from '@kbn/unified-tabs'; -import useObservable from 'react-use/lib/useObservable'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; import { ENABLE_ESQL } from '@kbn/esql-utils'; import type { DataView } from '@kbn/data-views-plugin/common'; -import { discoverTopNavMenuContext } from '../top_nav/discover_topnav_menu'; import { internalStateActions, useInternalStateDispatch, @@ -26,6 +24,7 @@ import { import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { useIsEsqlMode } from '../../hooks/use_is_esql_mode'; import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; +import { useTopNavMenuItems } from '../top_nav/use_top_nav_menu_items'; const APP_MENU_COLLAPSE_THRESHOLD = 800; @@ -99,8 +98,7 @@ export const useAppMenuData = ({ currentDataView }: UseAppMenuDataParams): UseAp currentDataView, ]); - const { topNavMenu$ } = useContext(discoverTopNavMenuContext); - const topNavMenuItems = useObservable(topNavMenu$, topNavMenu$.getValue()); + const topNavMenuItems = useTopNavMenuItems(); return { shouldCollapseAppMenu, diff --git a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx index a9d21268ad4d3..220fe8d6d5f29 100644 --- a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx @@ -33,8 +33,9 @@ import { BrandedLoadingIndicator, NoDataPage, InitializationError, + SingleTabViewWithAppMenu, + SingleTabView, } from './components/single_tab_view'; -import { SingleTabViewWithAppMenu } from './components/single_tab_view/single_tab_view_with_app_menu'; import { useAsyncFunction } from './hooks/use_async_function'; import { TabsView } from './components/tabs_view'; import { ChartPortalsRenderer } from './components/chart'; @@ -104,7 +105,9 @@ const DiscoverMainRouteContent = (props: SingleTabViewProps) => { const history = useHistory(); const dispatch = useInternalStateDispatch(); const rootProfileState = useRootProfile(); - const tabsEnabled = !services.embeddableEditor.isByValueEditor(); + const tabsEnabled = + !services.embeddableEditor.isByValueEditor() && + customizationContext.displayMode === 'standalone'; const { initializeProfileDataViews } = useDefaultAdHocDataViews(); const [mainRouteInitializationState, initializeMainRoute] = useAsyncFunction( @@ -264,11 +267,21 @@ const DiscoverMainRouteContent = (props: SingleTabViewProps) => { defaultMessage: 'Discover - Session not yet saved', })} - {tabsEnabled && customizationContext.displayMode !== 'embedded' ? ( - - ) : ( - - )} + { + /** + * We need to account for three different display modes: + * - If tabs are enabled, show the tabs bar and the app menu. + * - If tabs are disabled and Discover is embedded, hide both the tabs bar and the app menu. + * - If tabs are disabled and Discover is standalone, hide the tabs bar but show the app menu. + */ + tabsEnabled ? ( + + ) : customizationContext.displayMode === 'embedded' ? ( + + ) : ( + + ) + } From bb75ed891f6dcdb795aae45a89aacc429e0273e2 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 22 Jan 2026 19:57:10 -0400 Subject: [PATCH 73/76] Only show switch to classic for the current tab --- .../components/tabs_view/use_app_menu_data.ts | 102 ++++++++++-------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts index 12afe5b7c64c4..d3869e34fed17 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_app_menu_data.ts @@ -7,9 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import type { EuiResizeObserverProps } from '@elastic/eui'; -import type { UnifiedTabsProps, TabMenuItem } from '@kbn/unified-tabs'; +import type { UnifiedTabsProps } from '@kbn/unified-tabs'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; @@ -20,11 +20,12 @@ import { useInternalStateDispatch, useInternalStateSelector, useCurrentTabAction, + selectAllTabs, } from '../../state_management/redux'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; -import { useIsEsqlMode } from '../../hooks/use_is_esql_mode'; import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; import { useTopNavMenuItems } from '../top_nav/use_top_nav_menu_items'; +import { isEsqlSource } from '../../../../../common/data_sources'; const APP_MENU_COLLAPSE_THRESHOLD = 800; @@ -42,61 +43,76 @@ interface UseAppMenuDataResult { export const useAppMenuData = ({ currentDataView }: UseAppMenuDataParams): UseAppMenuDataResult => { const services = useDiscoverServices(); const dispatch = useInternalStateDispatch(); - const isEsqlMode = useIsEsqlMode(); + const allTabs = useInternalStateSelector(selectAllTabs); const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId); const unsavedTabIds = useInternalStateSelector((state) => state.tabs.unsavedIds); + const persistedDiscoverSession = useInternalStateSelector( + (state) => state.persistedDiscoverSession + ); const [shouldCollapseAppMenu, setShouldCollapseAppMenu] = useState(false); const transitionFromESQLToDataView = useCurrentTabAction( internalStateActions.transitionFromESQLToDataView ); - // Determine if we should show the ES|QL to Data View transition modal - const persistedDiscoverSession = useInternalStateSelector( - (state) => state.persistedDiscoverSession - ); - const shouldShowESQLToDataViewTransitionModal = - !persistedDiscoverSession || unsavedTabIds.includes(currentTabId); - const onResize: EuiResizeObserverProps['onResize'] = useCallback((dimensions) => { if (!dimensions) return; setShouldCollapseAppMenu(dimensions.width < APP_MENU_COLLAPSE_THRESHOLD); }, []); - // Provide "Switch to Classic" menu item for tabs when in ES|QL mode - const getAdditionalTabMenuItems: UnifiedTabsProps['getAdditionalTabMenuItems'] = useMemo(() => { - if (!isEsqlMode || !services.uiSettings.get(ENABLE_ESQL)) { - return undefined; - } + // Provide "Switch to Classic" menu item for the selected tab when in ES|QL mode + const getAdditionalTabMenuItems = useCallback< + NonNullable + >( + (item) => { + if (!services.uiSettings.get(ENABLE_ESQL)) { + return []; + } + + const tab = allTabs.find((t) => t.id === item.id); + const isCurrentTab = tab?.id === currentTabId; + + if (!isCurrentTab || !isEsqlSource(tab.appState.dataSource)) { + return []; + } - return (): TabMenuItem[] => [ - { - 'data-test-subj': 'unifiedTabs_tabMenuItem_switchToClassic', - name: 'switchToClassic', - label: i18n.translate('discover.localMenu.switchToClassicTitle', { - defaultMessage: 'Switch to classic', - }), - onClick: () => { - services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:back_to_classic_clicked`); - if ( - shouldShowESQLToDataViewTransitionModal && - !services.storage.get(ESQL_TRANSITION_MODAL_KEY) - ) { - dispatch(internalStateActions.setIsESQLToDataViewTransitionModalVisible(true)); - } else { - dispatch(transitionFromESQLToDataView({ dataViewId: currentDataView?.id ?? '' })); - } + return [ + { + 'data-test-subj': 'unifiedTabs_tabMenuItem_switchToClassic', + name: 'switchToClassic', + label: i18n.translate('discover.localMenu.switchToClassicTitle', { + defaultMessage: 'Switch to classic', + }), + onClick: () => { + services.trackUiMetric?.(METRIC_TYPE.CLICK, `esql:back_to_classic_clicked`); + + // Determine if we should show the ES|QL to Data View transition modal + const shouldShowESQLToDataViewTransitionModal = + !persistedDiscoverSession || unsavedTabIds.includes(tab.id); + + if ( + shouldShowESQLToDataViewTransitionModal && + !services.storage.get(ESQL_TRANSITION_MODAL_KEY) + ) { + dispatch(internalStateActions.setIsESQLToDataViewTransitionModalVisible(true)); + } else { + dispatch(transitionFromESQLToDataView({ dataViewId: currentDataView?.id ?? '' })); + } + }, }, - }, - ]; - }, [ - isEsqlMode, - services, - shouldShowESQLToDataViewTransitionModal, - dispatch, - transitionFromESQLToDataView, - currentDataView, - ]); + ]; + }, + [ + allTabs, + currentDataView?.id, + currentTabId, + dispatch, + persistedDiscoverSession, + services, + transitionFromESQLToDataView, + unsavedTabIds, + ] + ); const topNavMenuItems = useTopNavMenuItems(); From 1dc3842cee106b75fdd0c751844c3030f741f85e Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 22 Jan 2026 20:38:01 -0400 Subject: [PATCH 74/76] Put the embedded editor cancel button into the split button menu --- .../components/top_nav/use_top_nav_links.tsx | 58 +++++++++---------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index ce412a10babbd..7119d2fa07172 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -255,22 +255,6 @@ export const useTopNavLinks = ({ if (services.capabilities.discover_v2.save && !defaultMenu?.saveItem?.disabled) { const isEmbeddedEditor = services.embeddableEditor.isEmbeddedEditor(); - // In embedded editor mode, add "Cancel" button - if (isEmbeddedEditor) { - newAppMenuRegistry.registerItems([ - { - id: 'cancel', - order: 100, - label: i18n.translate('discover.localMenu.cancelTitle', { - defaultMessage: 'Cancel', - }), - testId: 'discoverCancelButton', - run: services.embeddableEditor.transferBackToEditor, - iconType: 'editorUndo', - }, - ]); - } - newAppMenuRegistry.setPrimaryActionItem({ id: 'save', label: isEmbeddedEditor @@ -289,26 +273,36 @@ export const useTopNavLinks = ({ onSaveCb: isEmbeddedEditor ? services.embeddableEditor.transferBackToEditor : undefined, }); }, - // Only show split button options when not in embedded editor mode - ...(isEmbeddedEditor - ? {} - : { - popoverWidth: 150, - popoverTestId: 'discoverSaveButtonPopover', - splitButtonProps: { + popoverWidth: 150, + popoverTestId: 'discoverSaveButtonPopover', + splitButtonProps: { + secondaryButtonIcon: 'arrowDown', + secondaryButtonAriaLabel: i18n.translate('discover.localMenu.saveOptionsAriaLabel', { + defaultMessage: 'Save options', + }), + // Show different split button options when in embedded editor mode + ...(isEmbeddedEditor + ? { + items: [ + { + run: services.embeddableEditor.transferBackToEditor, + id: 'cancel', + order: 100, + label: i18n.translate('discover.localMenu.cancelTitle', { + defaultMessage: 'Cancel', + }), + iconType: 'editorUndo', + testId: 'discoverCancelButton', + }, + ], + } + : { showNotificationIndicator: hasUnsavedChanges, notifcationIndicatorTooltipContent: hasUnsavedChanges ? i18n.translate('discover.localMenu.unsavedChangesTooltip', { defaultMessage: 'You have unsaved changes', }) : undefined, - secondaryButtonIcon: 'arrowDown', - secondaryButtonAriaLabel: i18n.translate( - 'discover.localMenu.saveOptionsAriaLabel', - { - defaultMessage: 'Save options', - } - ), items: [ { run: async () => { @@ -348,8 +342,8 @@ export const useTopNavLinks = ({ disableButton: !hasUnsavedChanges, }, ], - }, - }), + }), + }, }); } From 599e81f2a1f8a06bdc022f132beb77ea1cfc61e9 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 22 Jan 2026 21:18:56 -0400 Subject: [PATCH 75/76] Continue passing topNavBadges through DiscoverTopNavMenu to prevent UI flickering --- .../top_nav/discover_topnav.test.tsx | 2 +- .../components/top_nav/discover_topnav.tsx | 4 +- .../top_nav/discover_topnav_menu.tsx | 39 +++++++++++++++++-- .../components/top_nav/use_discover_topnav.ts | 11 +----- .../application/main/discover_main_route.tsx | 2 +- 5 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx index 211bf5a8f9b1d..bb29f3cb6df3d 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.test.tsx @@ -104,7 +104,7 @@ const getTestComponent = (props: DiscoverTopNavProps) => stateContainer={props.stateContainer} runtimeState={{ currentDataView: dataViewMock, adHocDataViews: [] }} > - + diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx index 03f6397c92b83..16b7da2d26014 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -236,7 +236,7 @@ export const DiscoverTopNav = ({ [dataView.id, dispatch, services, stateContainer, transitionFromESQLToDataView] ); - const { topNavMenu } = useDiscoverTopNav({ + const { topNavBadges, topNavMenu } = useDiscoverTopNav({ stateContainer, persistedDiscoverSession, }); @@ -342,7 +342,7 @@ export const DiscoverTopNav = ({ return ( - + ({ topNavMenu$: new BehaviorSubject(undefined), + topNavBadges$: new BehaviorSubject(undefined), }); type DiscoverTopNavMenuContext = ReturnType; @@ -36,10 +42,32 @@ export const discoverTopNavMenuContext = createContext { +export const DiscoverTopNavMenuProvider = ({ + customizationContext, + children, +}: PropsWithChildren<{ customizationContext: DiscoverCustomizationContext }>) => { + const { chrome } = useDiscoverServices(); const [topNavMenuContext] = useState(() => createTopNavMenuContext()); + const topNavBadges = useObservable( + topNavMenuContext.topNavBadges$, + topNavMenuContext.topNavBadges$.getValue() + ); + + useEffect(() => { + if (customizationContext.displayMode === 'embedded') { + return; + } + + chrome.setBreadcrumbsBadges(topNavBadges ?? []); + + return () => { + chrome.setBreadcrumbsBadges([]); + }; + }, [chrome, customizationContext.displayMode, topNavBadges]); + useUnmount(() => { + topNavMenuContext.topNavBadges$.next(undefined); topNavMenuContext.topNavMenu$.next(undefined); }); @@ -51,9 +79,14 @@ export const DiscoverTopNavMenuProvider = ({ children }: PropsWithChildren) => { }; export const DiscoverTopNavMenu = ({ + topNavBadges, topNavMenu, -}: Pick, 'topNavMenu'>) => { - const { topNavMenu$ } = useContext(discoverTopNavMenuContext); +}: ReturnType) => { + const { topNavBadges$, topNavMenu$ } = useContext(discoverTopNavMenuContext); + + useLayoutEffect(() => { + topNavBadges$.next(topNavBadges); + }, [topNavBadges, topNavBadges$]); useLayoutEffect(() => { topNavMenu$.next(topNavMenu); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts index eea80e77aba56..f43ed87feb7bf 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_discover_topnav.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import { useIsWithinBreakpoints } from '@elastic/eui'; import { useDiscoverCustomization } from '../../../../customizations'; @@ -46,15 +46,6 @@ export const useDiscoverTopNav = ({ [stateContainer, services, isMobile] ); - useEffect(() => { - if (stateContainer.customizationContext.displayMode === 'standalone') { - services.chrome.setBreadcrumbsBadges(topNavBadges); - return () => { - services.chrome.setBreadcrumbsBadges([]); - }; - } - }, [topNavBadges, services.chrome, stateContainer.customizationContext.displayMode]); - const dataView = useCurrentDataView(); const adHocDataViews = useAdHocDataViews(); const isEsqlMode = useIsEsqlMode(); diff --git a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx index 220fe8d6d5f29..b4bd5329bc76a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx @@ -253,7 +253,7 @@ const DiscoverMainRouteContent = (props: SingleTabViewProps) => { return ( - + <>

{persistedDiscoverSession?.title From 0e1f756baaee9afecfe17b1248c246fa3d93d35d Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Fri, 23 Jan 2026 07:43:03 +0100 Subject: [PATCH 76/76] Fix example customization tests --- examples/discover_customization_examples/public/plugin.tsx | 1 + .../components/discover_container/discover_container.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/discover_customization_examples/public/plugin.tsx b/examples/discover_customization_examples/public/plugin.tsx index 633248df71179..c13c29782d89b 100644 --- a/examples/discover_customization_examples/public/plugin.tsx +++ b/examples/discover_customization_examples/public/plugin.tsx @@ -74,6 +74,7 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin { }} scopedHistory={appMountParams.history} customizationCallbacks={[this.customizationCallback]} + customizationContext={{ displayMode: 'standalone' }} /> diff --git a/src/platform/plugins/shared/discover/public/components/discover_container/discover_container.tsx b/src/platform/plugins/shared/discover/public/components/discover_container/discover_container.tsx index 7e6859c0cacb9..4005d07f560bc 100644 --- a/src/platform/plugins/shared/discover/public/components/discover_container/discover_container.tsx +++ b/src/platform/plugins/shared/discover/public/components/discover_container/discover_container.tsx @@ -30,6 +30,7 @@ export interface DiscoverContainerInternalProps { getDiscoverServices: () => Promise; scopedHistory: ScopedHistory; customizationCallbacks: CustomizationCallback[]; + customizationContext?: DiscoverCustomizationContext; stateStorageContainer?: IKbnUrlStateStorage; isLoading?: boolean; } @@ -45,12 +46,13 @@ const discoverContainerWrapperCss = css` } `; -const customizationContext: DiscoverCustomizationContext = { displayMode: 'embedded' }; +const defaultCustomizationContext: DiscoverCustomizationContext = { displayMode: 'embedded' }; export const DiscoverContainerInternal = ({ overrideServices, scopedHistory, customizationCallbacks, + customizationContext = defaultCustomizationContext, getDiscoverServices, stateStorageContainer, isLoading = false,