From 567e3b6dcf09e379eb9397c5b837204ad4e5f47a Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Tue, 31 Mar 2026 12:03:50 +0200 Subject: [PATCH 01/14] add launchpad to classic nav with sub links and remove landing page navigation interation from top level nav links refs https://github.com/elastic/security-team/issues/16454 https://github.com/elastic/security-team/issues/16455 --- .../shared/deeplinks/security/deep_links.ts | 1 + .../security/packages/side-nav/index.ts | 6 +- .../security/packages/side-nav/src/index.tsx | 4 +- .../side-nav/src/solution_side_nav.styles.ts | 13 +- .../side-nav/src/solution_side_nav.tsx | 138 ++++++++-- .../security/packages/side-nav/src/types.ts | 1 + .../common/experimental_features.ts | 5 + .../public/app/translations.ts | 4 + .../breadcrumbs/use_breadcrumbs_nav.ts | 2 +- .../security_side_nav/categories.ts | 27 +- .../security_side_nav/security_side_nav.tsx | 254 ++++++++++++++---- .../public/onboarding/links.ts | 46 +++- 12 files changed, 408 insertions(+), 93 deletions(-) diff --git a/src/platform/packages/shared/deeplinks/security/deep_links.ts b/src/platform/packages/shared/deeplinks/security/deep_links.ts index 0ff454d6c2b23..d1dfcc44748a4 100644 --- a/src/platform/packages/shared/deeplinks/security/deep_links.ts +++ b/src/platform/packages/shared/deeplinks/security/deep_links.ts @@ -59,6 +59,7 @@ export enum SecurityPageName { hostsUncommonProcesses = 'hosts-uncommon_processes', kubernetes = 'kubernetes', landing = 'get_started', + launchpad = 'launchpad', network = 'network', networkAnomalies = 'network-anomalies', networkDns = 'network-dns', diff --git a/x-pack/solutions/security/packages/side-nav/index.ts b/x-pack/solutions/security/packages/side-nav/index.ts index acc05924544fc..fe2e1fc88deb8 100644 --- a/x-pack/solutions/security/packages/side-nav/index.ts +++ b/x-pack/solutions/security/packages/side-nav/index.ts @@ -5,6 +5,10 @@ * 2.0. */ -export { SolutionSideNav, type SolutionSideNavProps } from './src'; +export { + SolutionSideNav, + type SolutionSideNavProps, + type SolutionSideNavInteractionVariant, +} from './src'; export { SolutionSideNavItemPosition } from './src/types'; export type { SolutionSideNavItem, Tracker } from './src/types'; diff --git a/x-pack/solutions/security/packages/side-nav/src/index.tsx b/x-pack/solutions/security/packages/side-nav/src/index.tsx index 055c45a1a8519..b24d4f856dfcc 100644 --- a/x-pack/solutions/security/packages/side-nav/src/index.tsx +++ b/x-pack/solutions/security/packages/side-nav/src/index.tsx @@ -7,9 +7,9 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; -import type { SolutionSideNavProps } from './solution_side_nav'; +import type { SolutionSideNavInteractionVariant, SolutionSideNavProps } from './solution_side_nav'; -export type { SolutionSideNavProps }; +export type { SolutionSideNavProps, SolutionSideNavInteractionVariant }; const SolutionSideNavLazy = lazy(() => import('./solution_side_nav')); diff --git a/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.styles.ts b/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.styles.ts index e390c03157ffb..8e72735614ac0 100644 --- a/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.styles.ts +++ b/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.styles.ts @@ -5,16 +5,23 @@ * 2.0. */ -import { transparentize, type EuiThemeComputed } from '@elastic/eui'; +import { type EuiThemeComputed, transparentize } from '@elastic/eui'; import { css } from '@emotion/css'; -export const SolutionSideNavItemStyles = (euiTheme: EuiThemeComputed<{}>) => css` +export type SolutionSideNavSelectedAppearance = 'default' | 'primary'; + +export const SolutionSideNavItemStyles = ( + euiTheme: EuiThemeComputed<{}>, + selectedAppearance: SolutionSideNavSelectedAppearance = 'default' +) => css` * { // EuiListGroupItem changes the links font-weight, we need to override it font-weight: ${euiTheme.font.weight.regular}; } &.solutionSideNavItem--isSelected { - background-color: ${transparentize(euiTheme.colors.lightShade, 0.5)}; + background-color: ${selectedAppearance === 'primary' + ? euiTheme.colors.backgroundBasePrimary + : transparentize(euiTheme.colors.lightShade, 0.5)}; & * { font-weight: ${euiTheme.font.weight.medium}; } diff --git a/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.tsx b/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.tsx index 357aaf583977f..928a15c8a0bf5 100644 --- a/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.tsx +++ b/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.tsx @@ -7,16 +7,16 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { - EuiListGroup, + EuiButtonIcon, EuiFlexGroup, EuiFlexItem, - useIsWithinBreakpoints, - useEuiTheme, - EuiListGroupItem, EuiHorizontalRule, - EuiSpacer, - EuiButtonIcon, EuiIcon, + EuiListGroup, + EuiListGroupItem, + EuiSpacer, + useEuiTheme, + useIsWithinBreakpoints, } from '@elastic/eui'; import { partition } from 'lodash/fp'; import classNames from 'classnames'; @@ -35,6 +35,12 @@ export const TOGGLE_PANEL_LABEL = i18n.translate('securitySolutionPackages.sideN defaultMessage: 'Toggle panel nav', }); +/** + * - `splitButton`: separate link row and spaces button to open the secondary panel (default) + * - `unifiedRow`: single row with trailing chevron; primary click navigates to the first child and toggles the panel + */ +export type SolutionSideNavInteractionVariant = 'splitButton' | 'unifiedRow'; + export interface SolutionSideNavProps { /** All the items to display in the side navigation */ items: SolutionSideNavItem[]; @@ -46,6 +52,10 @@ export interface SolutionSideNavProps { panelBottomOffset?: string; /** Css value for the top offset of the secondary panel. defaults to the generic kibana header height */ panelTopOffset?: string; + /** + * Presentation and interaction for panel opener rows. When `unifiedRow`, selected rows use primary background. + */ + navLinkInteractionVariant?: SolutionSideNavInteractionVariant; /** * The tracker function to enable navigation Telemetry, this has to be bound with the plugin `appId` * e.g.: usageCollection?.reportUiCounter?.bind(null, appId) @@ -62,6 +72,7 @@ export const SolutionSideNav: React.FC = React.memo(functi selectedId, panelBottomOffset, panelTopOffset, + navLinkInteractionVariant = 'splitButton', tracker, }) { const isMobileSize = useIsWithinBreakpoints(['xs', 's']); @@ -121,6 +132,8 @@ export const SolutionSideNav: React.FC = React.memo(functi activePanelNavId={activePanelNavId} isMobileSize={isMobileSize} onOpenPanelNav={openPanelNav} + onClosePanelNav={onClosePanelNav} + navLinkInteractionVariant={navLinkInteractionVariant} /> @@ -131,6 +144,8 @@ export const SolutionSideNav: React.FC = React.memo(functi activePanelNavId={activePanelNavId} isMobileSize={isMobileSize} onOpenPanelNav={openPanelNav} + onClosePanelNav={onClosePanelNav} + navLinkInteractionVariant={navLinkInteractionVariant} /> @@ -155,6 +170,8 @@ interface SolutionSideNavItemsProps { activePanelNavId: ActivePanelNav; isMobileSize: boolean; onOpenPanelNav: (id: string) => void; + onClosePanelNav: () => void; + navLinkInteractionVariant: SolutionSideNavInteractionVariant; categories?: SeparatorLinkCategory[]; } /** @@ -170,6 +187,8 @@ const SolutionSideNavItems: React.FC = React.memo( activePanelNavId, isMobileSize, onOpenPanelNav, + onClosePanelNav, + navLinkInteractionVariant, }) { if (!categories?.length) { return ( @@ -182,6 +201,8 @@ const SolutionSideNavItems: React.FC = React.memo( isActive={activePanelNavId === item.id} isMobileSize={isMobileSize} onOpenPanelNav={onOpenPanelNav} + onClosePanelNav={onClosePanelNav} + navLinkInteractionVariant={navLinkInteractionVariant} /> ))} @@ -214,6 +235,8 @@ const SolutionSideNavItems: React.FC = React.memo( isActive={activePanelNavId === item.id} isMobileSize={isMobileSize} onOpenPanelNav={onOpenPanelNav} + onClosePanelNav={onClosePanelNav} + navLinkInteractionVariant={navLinkInteractionVariant} /> ))} @@ -230,7 +253,9 @@ interface SolutionSideNavItemProps { isSelected: boolean; isActive: boolean; onOpenPanelNav: (id: string) => void; + onClosePanelNav: () => void; isMobileSize: boolean; + navLinkInteractionVariant: SolutionSideNavInteractionVariant; } /** * The Solution side navigation item component. @@ -238,13 +263,40 @@ interface SolutionSideNavItemProps { * and it adds a button to open the item secondary panel if needed. */ const SolutionSideNavItem: React.FC = React.memo( - function SolutionSideNavItem({ item, isSelected, isActive, isMobileSize, onOpenPanelNav }) { + function SolutionSideNavItem({ + item, + isSelected, + isActive, + isMobileSize, + onOpenPanelNav, + navLinkInteractionVariant, + }) { const { euiTheme } = useEuiTheme(); const { tracker } = useTelemetryContext(); - const { id, href, label, items, onClick, iconType, appendSeparator } = item; + const { + id, + href, + label, + items: childItems, + onClick, + iconType, + appendSeparator, + prependSeparator, + } = item; + + const firstPanelChild = useMemo( + () => childItems?.find((child) => !child.disabled), + [childItems] + ); - const solutionSideNavItemStyles = SolutionSideNavItemStyles(euiTheme); + const effectiveHref = + navLinkInteractionVariant === 'unifiedRow' && firstPanelChild ? firstPanelChild.href : href; + + const solutionSideNavItemStyles = SolutionSideNavItemStyles( + euiTheme, + navLinkInteractionVariant === 'unifiedRow' && isSelected ? 'primary' : 'default' + ); const itemClassNames = classNames( 'solutionSideNavItem', { 'solutionSideNavItem--isSelected': isSelected }, @@ -253,16 +305,42 @@ const SolutionSideNavItem: React.FC = React.memo( const buttonClassNames = classNames('solutionSideNavItemButton'); const hasPanelNav = useMemo( - () => !isMobileSize && items != null && items.length > 0, - [items, isMobileSize] + () => !isMobileSize && childItems != null && childItems.length > 0, + [childItems, isMobileSize] ); + const listItemLabel = useMemo(() => { + if (hasPanelNav && navLinkInteractionVariant === 'unifiedRow') { + return ( + + {label} + + + + + ); + } + return label; + }, [hasPanelNav, navLinkInteractionVariant, label, id, euiTheme.size.s]); + const onLinkClicked: React.MouseEventHandler = useCallback( (ev) => { tracker?.(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.NAVIGATION}${id}`); onClick?.(ev); }, - [id, onClick, tracker] + [tracker, id, onClick] ); const onButtonClick: React.MouseEventHandler = useCallback(() => { @@ -270,30 +348,32 @@ const SolutionSideNavItem: React.FC = React.memo( onOpenPanelNav(id); }, [id, onOpenPanelNav, tracker]); - const itemLabel = useMemo(() => { - if (iconType == null) { - return label; - } - return ( - - {label} - - - - - ); - }, [iconType, label, id]); + const onSingleRowLinkClick: React.MouseEventHandler = useCallback( + (ev) => { + ev.preventDefault(); + if (!hasPanelNav) { + onLinkClicked(ev); + return; + } + onButtonClick(ev); + }, + [hasPanelNav, onButtonClick, onLinkClicked] + ); return ( <> + {prependSeparator ? : null} = React.memo( /> - {hasPanelNav && ( + {hasPanelNav && navLinkInteractionVariant === 'splitButton' && ( { items?: Array>; categories?: LinkCategories; iconType?: IconType; + prependSeparator?: boolean; appendSeparator?: boolean; position?: SolutionSideNavItemPosition; disabled?: boolean; diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts index 6f628037eedfa..2fef70a6454c4 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts @@ -252,6 +252,11 @@ export const allowedExperimentalValues = Object.freeze({ * Uses entity store v2 for entity analytics skill */ entityAnalyticsEntityStoreV2: false, + + /** + * Classic chrome only: refreshed Security side nav (Launchpad, Dev Tools, Management footer; unified row + panel behavior). + */ + securityClassicNavUpdate: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts index bb06f0a2d7104..e3f7f6b643fc4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts @@ -58,6 +58,10 @@ export const HOSTS = i18n.translate('xpack.securitySolution.navigation.hosts', { defaultMessage: 'Hosts', }); +export const LAUNCHPAD = i18n.translate('xpack.securitySolution.navigation.launchpad', { + defaultMessage: 'Launchpad', +}); + export const GETTING_STARTED = i18n.translate('xpack.securitySolution.navigation.gettingStarted', { defaultMessage: 'Get started', }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.ts index f8cb862bde127..7272e647028aa 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.ts @@ -54,7 +54,7 @@ const getLeadingBreadcrumbs = ( ): ChromeBreadcrumb[] => { const landingBreadcrumb: ChromeBreadcrumb = { text: APP_NAME, - href: getSecuritySolutionUrl({ deepLinkId: SecurityPageName.landing }), + href: getSecuritySolutionUrl({ deepLinkId: SecurityPageName.launchpad }), }; const breadcrumbs: ChromeBreadcrumb[] = parentLinks.map(({ title, id }) => ({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts index cfc4cfe621297..b99234840c829 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts @@ -9,9 +9,10 @@ import { LinkCategoryType, type SeparatorLinkCategory } from '@kbn/security-solu import { SecurityPageName } from '../../../../../common'; export const getNavCategories = ( - enableAlertsAndAttacksAlignment?: boolean + enableAlertsAndAttacksAlignment?: boolean, + securityClassicNavUpdate?: boolean ): SeparatorLinkCategory[] => { - return [ + const categories: SeparatorLinkCategory[] = [ { type: LinkCategoryType.separator, linkIds: [SecurityPageName.dashboards], @@ -38,13 +39,19 @@ export const getNavCategories = ( SecurityPageName.assetInventory, ], }, - { - type: LinkCategoryType.separator, - linkIds: [ - SecurityPageName.siemReadiness, - SecurityPageName.aiValue, - SecurityPageName.siemMigrationsLanding, - ], - }, ]; + + return securityClassicNavUpdate + ? [ + ...categories, + { + type: LinkCategoryType.separator, + linkIds: [ + SecurityPageName.siemReadiness, + SecurityPageName.aiValue, + SecurityPageName.siemMigrationsLanding, + ], + }, + ] + : categories; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx index 6bf356589d7b2..1ee51e7bac2c3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -7,64 +7,98 @@ import React, { useMemo } from 'react'; import { EuiLoadingSpinner, useEuiTheme } from '@elastic/eui'; +import { + LinkCategoryType, + SecurityGroupName, + SecurityPageName, +} from '@kbn/security-solution-navigation'; +import { i18nStrings } from '@kbn/security-solution-navigation/links'; import { SolutionSideNav, - SolutionSideNavItemPosition, + type SolutionSideNavInteractionVariant, type SolutionSideNavItem, + SolutionSideNavItemPosition, } from '@kbn/security-solution-side-nav'; import useObservable from 'react-use/lib/useObservable'; import { ENABLE_ALERTS_AND_ATTACKS_ALIGNMENT_SETTING } from '../../../../../common/constants'; -import { SecurityPageName } from '../../../../app/types'; -import type { NavigationLink } from '../../../links'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; import { useRouteSpy } from '../../../utils/route/use_route_spy'; -import { useGetSecuritySolutionLinkProps, type GetSecuritySolutionLinkProps } from '../../links'; +import { type GetSecuritySolutionLinkProps, useGetSecuritySolutionLinkProps } from '../../links'; import { useNavLinks } from '../../../links/nav_links'; +import type { NavigationLink } from '../../../links/types'; import { useShowTimeline } from '../../../utils/timeline/use_show_timeline'; import { useIsPolicySettingsBarVisible } from '../../../../management/pages/policy/view/policy_hooks'; import { track } from '../../../lib/telemetry'; import { useKibana } from '../../../lib/kibana'; import { getNavCategories } from './categories'; import { useParentLinks } from '../../../links/links_hooks'; +import { CLASSIC_LAUNCHPAD_PANEL_LINK_ENTRIES } from '../../../../onboarding/links'; export const EUI_HEADER_HEIGHT = '93px'; + +const flattenNavigationLinks = (links: NavigationLink[]): NavigationLink[] => + links.flatMap((link) => [ + link, + ...(link.links?.length ? flattenNavigationLinks(link.links) : []), + ]); export const BOTTOM_BAR_HEIGHT = '50px'; -const getNavItemPosition = (id: SecurityPageName): SolutionSideNavItemPosition => - id === SecurityPageName.landing || id === SecurityPageName.administration +const getNavItemPosition = ( + id: SecurityPageName, + isClassicNavUpdateLayout: boolean +): SolutionSideNavItemPosition => { + if (isClassicNavUpdateLayout) { + return SolutionSideNavItemPosition.top; + } + return id === SecurityPageName.landing || id === SecurityPageName.administration ? SolutionSideNavItemPosition.bottom : SolutionSideNavItemPosition.top; +}; -const isGetStartedNavItem = (id: SecurityPageName) => id === SecurityPageName.landing; +const isGetStartedNavItem = (id: SecurityPageName, isClassicNavUpdateLayout: boolean): boolean => + !isClassicNavUpdateLayout && id === SecurityPageName.landing; + +const LAUNCHPAD_PAGES: ReadonlySet = new Set([ + SecurityPageName.landing, + SecurityPageName.siemReadiness, + SecurityPageName.aiValue, + SecurityPageName.siemMigrationsLanding, + SecurityPageName.siemMigrationsRules, + SecurityPageName.siemMigrationsDashboards, +]); /** * Formats generic navigation links into the shape expected by the `SolutionSideNav` */ const formatLink = ( navLink: NavigationLink, - getSecuritySolutionLinkProps: GetSecuritySolutionLinkProps -): SolutionSideNavItem => ({ - id: navLink.id, - label: navLink.title, - position: getNavItemPosition(navLink.id), - ...getSecuritySolutionLinkProps({ deepLinkId: navLink.id }), - ...(navLink.sideNavIcon && { iconType: navLink.sideNavIcon }), - ...(navLink.categories?.length && { categories: navLink.categories }), - ...(navLink.links?.length && { - items: navLink.links.reduce((acc, current) => { - if (!current.disabled) { - acc.push({ - id: current.id, - label: current.title, - iconType: current.sideNavIcon, - isBeta: current.isBeta, - betaOptions: current.betaOptions, - ...getSecuritySolutionLinkProps({ deepLinkId: current.id }), - }); - } - return acc; - }, []), - }), -}); + getSecuritySolutionLinkProps: GetSecuritySolutionLinkProps, + options: { isClassicNavUpdateLayout: boolean } +): SolutionSideNavItem => { + return { + id: navLink.id, + label: navLink.title, + position: getNavItemPosition(navLink.id, options.isClassicNavUpdateLayout), + ...getSecuritySolutionLinkProps({ deepLinkId: navLink.id }), + ...(navLink.sideNavIcon && { iconType: navLink.sideNavIcon }), + ...(navLink.categories?.length && { categories: navLink.categories }), + ...(navLink.links?.length && { + items: navLink.links.reduce((acc, current) => { + if (!current.disabled) { + acc.push({ + id: current.id, + label: current.title, + iconType: current.sideNavIcon, + isBeta: current.isBeta, + betaOptions: current.betaOptions, + ...getSecuritySolutionLinkProps({ deepLinkId: current.id }), + }); + } + return acc; + }, []), + }), + }; +}; /** * Formats the get started navigation links into the shape expected by the `SolutionSideNav` @@ -81,37 +115,159 @@ const formatGetStartedLink = ( ...getSecuritySolutionLinkProps({ deepLinkId: navLink.id }), }); -/** - * Returns the formatted `items` and `footerItems` to be rendered in the navigation - */ -const useSolutionSideNavItems = () => { +const useSolutionSideNavItems = (isClassicNavUpdateLayout: boolean) => { const navLinks = useNavLinks(); const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); // adds href and onClick props + const classicFooterItems = useMemo((): SolutionSideNavItem[] | null => { + if (!isClassicNavUpdateLayout) { + return null; + } + + const flatNavLinks = flattenNavigationLinks(navLinks); + const authorizedNavById = new Map( + flatNavLinks + .filter((link) => !link.disabled && !link.unauthorized) + .map((link) => [link.id, link]) + ); + + const areMigrationLinks = (id: SecurityPageName) => + [SecurityPageName.siemMigrationsRules, SecurityPageName.siemMigrationsDashboards].includes( + id + ); + + const { launchpadPanelItems, launchpadCategories } = + CLASSIC_LAUNCHPAD_PANEL_LINK_ENTRIES.reduce<{ + launchpadPanelItems: SolutionSideNavItem[]; + launchpadCategories: Array<{ type: LinkCategoryType; label?: string; linkIds: string[] }>; + }>( + (acc, { id: pageId }) => { + const source = authorizedNavById.get(pageId); + if (source == null) { + return acc; + } + + const item: SolutionSideNavItem = { + id: pageId, + label: source.title, + ...getSecuritySolutionLinkProps({ deepLinkId: pageId }), + }; + + acc.launchpadPanelItems.push(item); + + // Initialize categories on first item + if (acc.launchpadPanelItems.length === 1) { + acc.launchpadCategories = [ + { + type: LinkCategoryType.separator, + label: undefined, + linkIds: [], + }, + { + type: LinkCategoryType.title, + label: i18nStrings.launchPad.migrations.title, + linkIds: [], + }, + ]; + } + + // Categorize the item + if (acc.launchpadCategories.length > 0) { + if (areMigrationLinks(pageId)) { + acc.launchpadCategories[1].linkIds.push(pageId); + } else { + acc.launchpadCategories[0].linkIds.push(pageId); + } + } + + return acc; + }, + { launchpadPanelItems: [], launchpadCategories: [] } + ); + + const launchpad: SolutionSideNavItem | null = + launchpadPanelItems.length > 0 + ? { + id: SecurityGroupName.launchpad, + label: i18nStrings.launchPad.title, + iconType: undefined, + position: SolutionSideNavItemPosition.bottom, + items: launchpadPanelItems, + categories: launchpadCategories, + prependSeparator: true, + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.landing }), + } + : null; + + const administrationNavLink = navLinks.find(({ id }) => id === SecurityPageName.administration); + const administrationFooter: SolutionSideNavItem | null = + administrationNavLink != null && !administrationNavLink.disabled + ? { + ...formatLink(administrationNavLink, getSecuritySolutionLinkProps, { + isClassicNavUpdateLayout, + }), + iconType: undefined, + position: SolutionSideNavItemPosition.bottom, + } + : null; + + return [ + ...(launchpad != null ? [launchpad] : []), + ...(administrationFooter != null ? [administrationFooter] : []), + ]; + }, [isClassicNavUpdateLayout, navLinks, getSecuritySolutionLinkProps]); + const sideNavItems = useMemo(() => { if (!navLinks?.length) { return undefined; } - return navLinks.reduce((navItems, navLink) => { + + const excluded = isClassicNavUpdateLayout ? new Set(LAUNCHPAD_PAGES) : undefined; + + const bodyItems = navLinks.reduce((navItems, navLink) => { if (navLink.disabled) { return navItems; } + if (excluded?.has(navLink.id)) { + return navItems; + } + + if (isClassicNavUpdateLayout && navLink.id === SecurityPageName.administration) { + return navItems; + } - if (isGetStartedNavItem(navLink.id)) { + if (isGetStartedNavItem(navLink.id, isClassicNavUpdateLayout)) { navItems.push(formatGetStartedLink(navLink, getSecuritySolutionLinkProps)); } else { - navItems.push(formatLink(navLink, getSecuritySolutionLinkProps)); + navItems.push( + formatLink(navLink, getSecuritySolutionLinkProps, { isClassicNavUpdateLayout }) + ); } return navItems; }, []); - }, [navLinks, getSecuritySolutionLinkProps]); + + if (isClassicNavUpdateLayout && classicFooterItems) { + return [...bodyItems, ...classicFooterItems]; + } + + return bodyItems; + }, [navLinks, getSecuritySolutionLinkProps, isClassicNavUpdateLayout, classicFooterItems]); return sideNavItems; }; -const useSelectedId = (): SecurityPageName => { +const useSelectedId = (isClassicNavUpdateLayout: boolean): string => { const [{ pageName }] = useRouteSpy(); const [rootLinkInfo] = useParentLinks(pageName); + + if (!isClassicNavUpdateLayout) { + return rootLinkInfo?.id ?? ''; + } + + if (LAUNCHPAD_PAGES.has(pageName)) { + return SecurityGroupName.launchpad; + } + return rootLinkInfo?.id ?? ''; }; @@ -135,9 +291,16 @@ const usePanelBottomOffset = (): string | undefined => { * It takes the links to render from the generic application `links` configs. */ export const SecuritySideNav: React.FC = () => { - const { uiSettings } = useKibana().services; - const items = useSolutionSideNavItems(); - const selectedId = useSelectedId(); + const { uiSettings, serverless } = useKibana().services; + const securityClassicNavUpdate = useIsExperimentalFeatureEnabled('securityClassicNavUpdate'); + const isClassicNavUpdateLayout = securityClassicNavUpdate && serverless == null; + + const navLinkInteractionVariant: SolutionSideNavInteractionVariant = isClassicNavUpdateLayout + ? 'unifiedRow' + : 'splitButton'; + + const items = useSolutionSideNavItems(isClassicNavUpdateLayout); + const selectedId = useSelectedId(isClassicNavUpdateLayout); const panelTopOffset = usePanelTopOffset(); const panelBottomOffset = usePanelBottomOffset(); @@ -146,8 +309,8 @@ export const SecuritySideNav: React.FC = () => { ENABLE_ALERTS_AND_ATTACKS_ALIGNMENT_SETTING, false ); - return getNavCategories(enableAlertsAndAttacksAlignment); - }, [uiSettings]); + return getNavCategories(enableAlertsAndAttacksAlignment, isClassicNavUpdateLayout); + }, [uiSettings, isClassicNavUpdateLayout]); if (!items) { return ; @@ -160,6 +323,7 @@ export const SecuritySideNav: React.FC = () => { selectedId={selectedId} panelTopOffset={panelTopOffset} panelBottomOffset={panelBottomOffset} + navLinkInteractionVariant={navLinkInteractionVariant} tracker={track} /> ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts index 7533cb60ed638..a5d36d7066c91 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts @@ -11,11 +11,15 @@ import { RULES_UI_READ_PRIVILEGE, SECURITY_UI_SHOW_PRIVILEGE, } from '@kbn/security-solution-features/constants'; +import { LinkCategoryType } from '@kbn/security-solution-navigation'; import { ONBOARDING_PATH, SecurityPageName } from '../../common/constants'; -import { GETTING_STARTED } from '../app/translations'; +import { GETTING_STARTED, LAUNCHPAD } from '../app/translations'; import type { LinkItem } from '../common/links/types'; +import { siemReadinessLinks } from '../siem_readiness/links'; +import { links as siemMigrationsLinks } from '../siem_migrations/links'; +import { aiValueLinks } from '../reports/links'; -export const onboardingLinks: LinkItem = { +const getStartedLink: LinkItem = { id: SecurityPageName.landing, title: GETTING_STARTED, path: ONBOARDING_PATH, @@ -25,8 +29,46 @@ export const onboardingLinks: LinkItem = { defaultMessage: 'Getting started', }), ], + hideTimeline: true, + skipUrlState: true, +}; + +export const onboardingLinks: LinkItem = { + id: SecurityPageName.launchpad, + title: LAUNCHPAD, + path: ONBOARDING_PATH, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.launchpad', { + defaultMessage: 'Launchpad', + }), + ], + categories: [ + { + type: LinkCategoryType.separator, + linkIds: [SecurityPageName.landing, SecurityPageName.aiValue], + }, + { + label: i18n.translate('xpack.securitySolution.appLinks.category.migrations', { + defaultMessage: 'Migrations', + }), + linkIds: [SecurityPageName.siemMigrationsLanding, SecurityPageName.siemMigrationsDashboards], + }, + ], + links: [getStartedLink, aiValueLinks, siemMigrationsLinks, siemReadinessLinks], sideNavIcon: 'launch', sideNavFooter: true, skipUrlState: true, hideTimeline: true, + visibleIn: ['globalSearch', 'sideNav'], }; + +/** + * Ordered entries: Get started, Value reports, Translated rules, Translated dashboards (titles come from link config). + */ +export const CLASSIC_LAUNCHPAD_PANEL_LINK_ENTRIES = Object.freeze([ + { id: SecurityPageName.landing }, + { id: SecurityPageName.siemReadiness }, + { id: SecurityPageName.aiValue }, + { id: SecurityPageName.siemMigrationsRules }, + { id: SecurityPageName.siemMigrationsDashboards }, +]); From 99ff01ca12f3f81ba1230bd78d785e93a4e530a3 Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Tue, 31 Mar 2026 12:05:17 +0200 Subject: [PATCH 02/14] remove dashboard sub nav panel open refs https://github.com/elastic/security-team/issues/16456 --- .../security_side_nav/security_side_nav.tsx | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx index 1ee51e7bac2c3..f35957954448b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -75,28 +75,32 @@ const formatLink = ( getSecuritySolutionLinkProps: GetSecuritySolutionLinkProps, options: { isClassicNavUpdateLayout: boolean } ): SolutionSideNavItem => { + const stripDashboardsPanel = + options.isClassicNavUpdateLayout && navLink.id === SecurityPageName.dashboards; + return { id: navLink.id, label: navLink.title, position: getNavItemPosition(navLink.id, options.isClassicNavUpdateLayout), ...getSecuritySolutionLinkProps({ deepLinkId: navLink.id }), ...(navLink.sideNavIcon && { iconType: navLink.sideNavIcon }), - ...(navLink.categories?.length && { categories: navLink.categories }), - ...(navLink.links?.length && { - items: navLink.links.reduce((acc, current) => { - if (!current.disabled) { - acc.push({ - id: current.id, - label: current.title, - iconType: current.sideNavIcon, - isBeta: current.isBeta, - betaOptions: current.betaOptions, - ...getSecuritySolutionLinkProps({ deepLinkId: current.id }), - }); - } - return acc; - }, []), - }), + ...(navLink.categories?.length && !stripDashboardsPanel && { categories: navLink.categories }), + ...(navLink.links?.length && + !stripDashboardsPanel && { + items: navLink.links.reduce((acc, current) => { + if (!current.disabled) { + acc.push({ + id: current.id, + label: current.title, + iconType: current.sideNavIcon, + isBeta: current.isBeta, + betaOptions: current.betaOptions, + ...getSecuritySolutionLinkProps({ deepLinkId: current.id }), + }); + } + return acc; + }, []), + }), }; }; From c82b2eefbfc2f1dc30e84f2f9236a13dddd2eebf Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Tue, 31 Mar 2026 16:05:54 +0200 Subject: [PATCH 03/14] fix feature flagged breadcrumbs and links --- .../public/app/links/app_links.ts | 12 ++++++--- .../breadcrumbs/use_breadcrumbs_nav.ts | 27 +++++++++++++++---- .../security_side_nav/security_side_nav.tsx | 6 +++-- .../public/onboarding/links.ts | 12 +++++---- .../security_solution/public/plugin.tsx | 2 +- 5 files changed, 42 insertions(+), 17 deletions(-) diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts b/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts index 498ea7c08392a..ca0e6c03608b7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts @@ -9,6 +9,7 @@ import { firstValueFrom } from 'rxjs'; import { AIChatExperience } from '@kbn/ai-assistant-common'; import { AI_CHAT_EXPERIENCE_TYPE } from '@kbn/management-settings-ids'; +import type { ExperimentalFeatures } from '../../../common'; import { ENABLE_ALERTS_AND_ATTACKS_ALIGNMENT_SETTING } from '../../../common/constants'; import { aiValueLinks } from '../../reports/links'; import { configurationsLinks, getConfigurationsLinks } from '../../configurations/links'; @@ -17,14 +18,14 @@ import { links as assetInventoryLinks } from '../../asset_inventory/links'; import { siemReadinessLinks } from '../../siem_readiness/links'; import type { AppLinkItems } from '../../common/links/types'; import { indicatorsLinks } from '../../threat_intelligence/links'; -import { alertDetectionsLinks, alertSummaryLink, alertsLink } from '../../detections/links'; +import { alertDetectionsLinks, alertsLink, alertSummaryLink } from '../../detections/links'; import { links as rulesLinks } from '../../rules/links'; import { links as siemMigrationsLinks } from '../../siem_migrations/links'; import { links as timelinesLinks } from '../../timelines/links'; import { links as casesLinks } from '../../cases/links'; -import { links as managementLinks, getManagementFilteredLinks } from '../../management/links'; +import { getManagementFilteredLinks, links as managementLinks } from '../../management/links'; import { exploreLinks } from '../../explore/links'; -import { onboardingLinks } from '../../onboarding/links'; +import { launchPadLinks, onboardingLinks } from '../../onboarding/links'; import { findingsLinks } from '../../cloud_security_posture/links'; import type { StartPlugins } from '../../types'; import { dashboardsLinks } from '../../dashboards/links'; @@ -53,7 +54,8 @@ export const appLinks: AppLinkItems = Object.freeze([ export const getFilteredLinks = async ( core: CoreStart, - plugins: StartPlugins + plugins: StartPlugins, + experimentalFeatures?: ExperimentalFeatures ): Promise => { const managementFilteredLinks = await getManagementFilteredLinks(core, plugins); @@ -64,6 +66,7 @@ export const getFilteredLinks = async ( const chatExperience: AIChatExperience = await firstValueFrom(chatExperience$); const filteredConfigurationsLinks = getConfigurationsLinks(chatExperience); + const isClassicNavUpdateEnabled = experimentalFeatures?.securityClassicNavUpdate ?? false; return Object.freeze([ dashboardsLinks, core.uiSettings.get(ENABLE_ALERTS_AND_ATTACKS_ALIGNMENT_SETTING, false) @@ -85,5 +88,6 @@ export const getFilteredLinks = async ( managementFilteredLinks, siemReadinessLinks, aiValueLinks, + ...(isClassicNavUpdateEnabled ? [launchPadLinks] : []), ]); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.ts index 7272e647028aa..95a744dd7b3be 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.ts @@ -16,13 +16,14 @@ import { TimelineId } from '../../../../../common/types/timeline'; import type { GetSecuritySolutionUrl } from '../../link_to'; import { useGetSecuritySolutionUrl } from '../../link_to'; import { AppEventTypes, type TelemetryServiceStart } from '../../../lib/telemetry'; -import { useKibana, useNavigateTo, type NavigateTo } from '../../../lib/kibana'; +import { type NavigateTo, useKibana, useNavigateTo } from '../../../lib/kibana'; import { useRouteSpy } from '../../../utils/route/use_route_spy'; import { updateBreadcrumbsNav } from '../../../breadcrumbs'; import type { LinkInfo } from '../../../links'; import { APP_NAME } from '../../../../../common/constants'; import { getTrailingBreadcrumbs } from './trailing_breadcrumbs'; import { useParentLinks } from '../../../links/links_hooks'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; export const useBreadcrumbsNav = () => { const dispatch = useDispatch(); @@ -31,6 +32,7 @@ export const useBreadcrumbsNav = () => { const { navigateTo } = useNavigateTo(); const getSecuritySolutionUrl = useGetSecuritySolutionUrl(); const parentLinks = useParentLinks(routeProps.pageName); + const isClassicNavUpdateEnabled = useIsExperimentalFeatureEnabled('securityClassicNavUpdate'); useEffect(() => { // cases manages its own breadcrumbs @@ -38,23 +40,38 @@ export const useBreadcrumbsNav = () => { return; } - const leadingBreadcrumbs = getLeadingBreadcrumbs(parentLinks, getSecuritySolutionUrl); + const leadingBreadcrumbs = getLeadingBreadcrumbs( + parentLinks, + getSecuritySolutionUrl, + isClassicNavUpdateEnabled + ); const trailingBreadcrumbs = getTrailingBreadcrumbs(routeProps, getSecuritySolutionUrl); updateBreadcrumbsNav({ leading: addOnClicksHandlers(leadingBreadcrumbs, dispatch, navigateTo, telemetry), trailing: addOnClicksHandlers(trailingBreadcrumbs, dispatch, navigateTo, telemetry), }); - }, [routeProps, parentLinks, getSecuritySolutionUrl, dispatch, navigateTo, telemetry]); + }, [ + routeProps, + parentLinks, + getSecuritySolutionUrl, + dispatch, + navigateTo, + telemetry, + isClassicNavUpdateEnabled, + ]); }; const getLeadingBreadcrumbs = ( parentLinks: LinkInfo[], - getSecuritySolutionUrl: GetSecuritySolutionUrl + getSecuritySolutionUrl: GetSecuritySolutionUrl, + isClassicNavUpdateEnabled: boolean ): ChromeBreadcrumb[] => { const landingBreadcrumb: ChromeBreadcrumb = { text: APP_NAME, - href: getSecuritySolutionUrl({ deepLinkId: SecurityPageName.launchpad }), + href: getSecuritySolutionUrl({ + deepLinkId: isClassicNavUpdateEnabled ? SecurityPageName.launchpad : SecurityPageName.landing, + }), }; const breadcrumbs: ChromeBreadcrumb[] = parentLinks.map(({ title, id }) => ({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx index f35957954448b..6c8bf218d5e4c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -296,8 +296,10 @@ const usePanelBottomOffset = (): string | undefined => { */ export const SecuritySideNav: React.FC = () => { const { uiSettings, serverless } = useKibana().services; - const securityClassicNavUpdate = useIsExperimentalFeatureEnabled('securityClassicNavUpdate'); - const isClassicNavUpdateLayout = securityClassicNavUpdate && serverless == null; + const isSecurityClassicNavUpdateEnabled = useIsExperimentalFeatureEnabled( + 'securityClassicNavUpdate' + ); + const isClassicNavUpdateLayout = isSecurityClassicNavUpdateEnabled && serverless == null; const navLinkInteractionVariant: SolutionSideNavInteractionVariant = isClassicNavUpdateLayout ? 'unifiedRow' diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts index a5d36d7066c91..10ebfa86d5df0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts @@ -19,7 +19,7 @@ import { siemReadinessLinks } from '../siem_readiness/links'; import { links as siemMigrationsLinks } from '../siem_migrations/links'; import { aiValueLinks } from '../reports/links'; -const getStartedLink: LinkItem = { +export const onboardingLinks: LinkItem = { id: SecurityPageName.landing, title: GETTING_STARTED, path: ONBOARDING_PATH, @@ -29,11 +29,13 @@ const getStartedLink: LinkItem = { defaultMessage: 'Getting started', }), ], - hideTimeline: true, + sideNavIcon: 'launch', + sideNavFooter: true, skipUrlState: true, + hideTimeline: true, }; -export const onboardingLinks: LinkItem = { +export const launchPadLinks: LinkItem = { id: SecurityPageName.launchpad, title: LAUNCHPAD, path: ONBOARDING_PATH, @@ -45,7 +47,7 @@ export const onboardingLinks: LinkItem = { categories: [ { type: LinkCategoryType.separator, - linkIds: [SecurityPageName.landing, SecurityPageName.aiValue], + linkIds: [SecurityPageName.landing, SecurityPageName.siemReadiness, SecurityPageName.aiValue], }, { label: i18n.translate('xpack.securitySolution.appLinks.category.migrations', { @@ -54,7 +56,7 @@ export const onboardingLinks: LinkItem = { linkIds: [SecurityPageName.siemMigrationsLanding, SecurityPageName.siemMigrationsDashboards], }, ], - links: [getStartedLink, aiValueLinks, siemMigrationsLinks, siemReadinessLinks], + links: [onboardingLinks, aiValueLinks, siemMigrationsLinks, siemReadinessLinks], sideNavIcon: 'launch', sideNavFooter: true, skipUrlState: true, diff --git a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx index 51fa015cc5476..d7fe59cb2201d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx @@ -573,7 +573,7 @@ export class Plugin implements IPlugin Date: Wed, 1 Apr 2026 14:24:23 +0200 Subject: [PATCH 04/14] tests --- .../side-nav/src/solution_side_nav.test.tsx | 178 +++++++++++++++++- .../security_side_nav.test.tsx | 161 ++++++++++++++-- 2 files changed, 320 insertions(+), 19 deletions(-) diff --git a/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.test.tsx b/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.test.tsx index 356e8deadee49..3cadad7ec78a2 100644 --- a/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.test.tsx +++ b/x-pack/solutions/security/packages/side-nav/src/solution_side_nav.test.tsx @@ -6,9 +6,10 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import { SolutionSideNav, type SolutionSideNavProps } from './solution_side_nav'; +import { SolutionSideNav } from './solution_side_nav'; +import type { SolutionSideNavProps } from './solution_side_nav'; import type { SolutionSideNavItem } from './types'; import { METRIC_TYPE } from '@kbn/analytics'; import { TELEMETRY_EVENT } from './telemetry/const'; @@ -169,4 +170,177 @@ describe('SolutionSideNav', () => { expect(result.getByText('Users')).toBeInTheDocument(); }); }); + + describe('unifiedRow interaction variant', () => { + const firstChildOnClick = jest.fn((ev: { preventDefault: () => void }) => { + ev.preventDefault(); + }); + const panelItems: SolutionSideNavItem[] = [ + { + id: 'dashboardsLanding', + label: 'Dashboards', + href: '/dashboards', + items: [ + { + id: 'overview', + label: 'Overview', + href: '/overview-first', + onClick: firstChildOnClick, + description: 'Overview description', + }, + ], + }, + { + id: 'alerts', + label: 'Alerts', + href: '/alerts', + }, + { + id: 'Rules', + label: 'Rules', + href: '/rules', + items: [ + { + id: 'rulesManagement', + label: 'Rules Management', + href: '/rules-management', + description: 'Rules Management description', + }, + ], + }, + ]; + + beforeEach(() => { + firstChildOnClick.mockClear(); + }); + + it('renders arrow hint instead of split panel button when item has children', () => { + const result = renderNav({ + items: panelItems, + navLinkInteractionVariant: 'unifiedRow', + selectedId: 'alerts', + }); + expect( + result.queryByTestId('solutionSideNavItemButton-dashboardsLanding') + ).not.toBeInTheDocument(); + expect( + result.getByTestId('solutionSideNavItemPanelHint-dashboardsLanding') + ).toBeInTheDocument(); + }); + + it('uses first child href on the parent row link', () => { + const result = renderNav({ + items: panelItems, + navLinkInteractionVariant: 'unifiedRow', + selectedId: 'alerts', + }); + expect( + result.getByTestId('solutionSideNavItemLink-dashboardsLanding').getAttribute('href') + ).toBe('/overview-first'); + }); + + it('clicking the parent row opens the panel', async () => { + const result = renderNav({ + items: panelItems, + navLinkInteractionVariant: 'unifiedRow', + selectedId: 'alerts', + }); + await userEvent.click(result.getByTestId('solutionSideNavItemLink-dashboardsLanding')); + expect(result.getByTestId('solutionSideNavPanel')).toBeInTheDocument(); + expect(result.getByText('Overview')).toBeInTheDocument(); + expect(mockTrack).toHaveBeenCalledWith( + METRIC_TYPE.CLICK, + `${TELEMETRY_EVENT.PANEL_NAVIGATION_TOGGLE}dashboardsLanding` + ); + }); + + it('should not show panel button in unifiedRow variant', () => { + const result = renderNav({ + items: panelItems, + navLinkInteractionVariant: 'unifiedRow', + selectedId: 'alerts', + }); + // Panel button should not exist for items with children in unifiedRow mode + expect( + result.queryByTestId('solutionSideNavItemButton-dashboardsLanding') + ).not.toBeInTheDocument(); + expect(result.queryByTestId('solutionSideNavItemButton-Rules')).not.toBeInTheDocument(); + }); + + it('should navigate directly when clicking item without children in unifiedRow mode', async () => { + const mockOnClick = jest.fn((ev) => { + ev.preventDefault(); + }); + const itemsWithoutChildren: SolutionSideNavItem[] = [ + { + id: 'alerts', + label: 'Alerts', + href: '/alerts', + onClick: mockOnClick, + }, + ]; + const result = renderNav({ + items: itemsWithoutChildren, + navLinkInteractionVariant: 'unifiedRow', + selectedId: 'alerts', + }); + await userEvent.click(result.getByTestId('solutionSideNavItemLink-alerts')); + expect(mockOnClick).toHaveBeenCalled(); + }); + }); + + describe('navLinkInteractionVariant attribute', () => { + it('should use splitButton as default variant', () => { + const result = renderNav(); + // In splitButton mode, panel button should be visible for items with children + expect(result.getByTestId('solutionSideNavItemButton-dashboardsLanding')).toBeInTheDocument(); + }); + + it('should apply unifiedRow variant correctly', () => { + const result = renderNav({ + items: mockItems, + navLinkInteractionVariant: 'unifiedRow', + selectedId: 'alerts', + }); + // In unifiedRow mode, arrow hint should show instead of button + expect( + result.getByTestId('solutionSideNavItemPanelHint-dashboardsLanding') + ).toBeInTheDocument(); + expect( + result.queryByTestId('solutionSideNavItemButton-dashboardsLanding') + ).not.toBeInTheDocument(); + }); + + it('should switch between variants correctly', () => { + const { rerender } = render( + + ); + + // Check splitButton mode + expect(screen.getByTestId('solutionSideNavItemButton-dashboardsLanding')).toBeInTheDocument(); + + // Re-render with unifiedRow + rerender( + + ); + + // Check unifiedRow mode + expect( + screen.getByTestId('solutionSideNavItemPanelHint-dashboardsLanding') + ).toBeInTheDocument(); + expect( + screen.queryByTestId('solutionSideNavItemButton-dashboardsLanding') + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx index fdf71083df565..5691cde542c21 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; import { SecurityPageName } from '../../../../app/types'; +import type { createMockStore } from '../../../mock/create_store'; import { TestProviders } from '../../../mock'; import { BOTTOM_BAR_HEIGHT, EUI_HEADER_HEIGHT, SecuritySideNav } from './security_side_nav'; import type { SolutionSideNavProps } from '@kbn/security-solution-side-nav'; @@ -76,9 +77,20 @@ jest.mock('../../../../management/pages/policy/view/policy_hooks', () => ({ useIsPolicySettingsBarVisible: () => mockUseIsPolicySettingsBarVisible(), })); -const renderNav = () => +const mockUseIsExperimentalFeatureEnabled = jest.fn((featureName: string) => { + if (featureName === 'securityClassicNavUpdate') { + return false; + } + return false; +}); +jest.mock('../../../hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: (featureName: string) => + mockUseIsExperimentalFeatureEnabled(featureName), +})); + +const renderNav = (options?: { store?: ReturnType }) => render(, { - wrapper: TestProviders, + wrapper: ({ children }) => {children}, }); describe('SecuritySideNav', () => { @@ -88,24 +100,28 @@ describe('SecuritySideNav', () => { useKibana().services.chrome.hasHeaderBanner$ = jest.fn(() => new BehaviorSubject(false).asObservable() ); + useKibana().services.serverless = undefined; + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); }); it('should render main items', () => { mockUseNavLinks.mockReturnValue([alertsNavLink]); renderNav(); - expect(mockSolutionSideNav).toHaveBeenCalledWith({ - selectedId: SecurityPageName.alerts, - items: [ - { - id: SecurityPageName.alerts, - label: 'alerts', - href: '/alerts', - position: 'top', - }, - ], - categories: getNavCategories(), - tracker: track, - }); + expect(mockSolutionSideNav).toHaveBeenCalledWith( + expect.objectContaining({ + selectedId: SecurityPageName.alerts, + items: [ + { + id: SecurityPageName.alerts, + label: 'alerts', + href: '/alerts', + position: 'top', + }, + ], + categories: getNavCategories(false, false), + tracker: track, + }) + ); }); it('should render the loader if items are still empty', () => { @@ -249,7 +265,7 @@ describe('SecuritySideNav', () => { renderNav(); expect(mockSolutionSideNav).toHaveBeenCalledWith( expect.objectContaining({ - categories: getNavCategories(true), + categories: getNavCategories(true, false), }) ); }); @@ -259,7 +275,118 @@ describe('SecuritySideNav', () => { renderNav(); expect(mockSolutionSideNav).toHaveBeenCalledWith( expect.objectContaining({ - categories: getNavCategories(false), + categories: getNavCategories(false, false), + }) + ); + }); + }); + + describe('securityClassicNavUpdate feature flag', () => { + beforeEach(() => { + useKibana().services.serverless = undefined; + }); + + it('should use splitButton interaction variant when classic nav update is disabled', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + renderNav(); + expect(mockSolutionSideNav).toHaveBeenCalledWith( + expect.objectContaining({ + navLinkInteractionVariant: 'splitButton', + }) + ); + }); + + it('should use unifiedRow interaction variant when classic nav update is enabled and not serverless', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + useKibana().services.serverless = undefined; + renderNav(); + expect(mockSolutionSideNav).toHaveBeenCalledWith( + expect.objectContaining({ + navLinkInteractionVariant: 'unifiedRow', + }) + ); + }); + + it('should use splitButton interaction variant when classic nav update is enabled but serverless is present', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + useKibana().services.serverless = {}; + renderNav(); + expect(mockSolutionSideNav).toHaveBeenCalledWith( + expect.objectContaining({ + navLinkInteractionVariant: 'splitButton', + }) + ); + }); + + it('should place administration item in footer when classic nav update is enabled', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseNavLinks.mockReturnValue([alertsNavLink, settingsNavLink]); + renderNav(); + expect(mockSolutionSideNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: SecurityPageName.administration, + position: 'bottom', + }), + ]), + }) + ); + }); + + it('should not include administration item in body when classic nav update is enabled', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseNavLinks.mockReturnValue([settingsNavLink, alertsNavLink]); + renderNav(); + const calls = mockSolutionSideNav.mock.calls; + const lastCall = calls[calls.length - 1]; + const items = lastCall[0].items; + const administrationItemsInBody = items.filter( + (item: any) => item.id === SecurityPageName.administration && item.position !== 'bottom' + ); + expect(administrationItemsInBody).toHaveLength(0); + }); + + it('should pass isClassicNavUpdateLayout true to getNavCategories when enabled', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + useKibana().services.uiSettings.get = jest.fn().mockReturnValue(false); + renderNav(); + expect(mockSolutionSideNav).toHaveBeenCalledWith( + expect.objectContaining({ + categories: getNavCategories(false, true), + }) + ); + }); + + it('should select launchpad when landing page is selected in classic nav layout', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseRouteSpy.mockReturnValue([{ pageName: SecurityPageName.landing }]); + const landingNavLink: NavigationLink = { + id: SecurityPageName.landing, + title: 'Get started', + description: 'Get started description', + }; + mockUseNavLinks.mockReturnValue([landingNavLink, alertsNavLink, settingsNavLink]); + renderNav(); + expect(mockSolutionSideNav).toHaveBeenCalledWith( + expect.objectContaining({ + selectedId: 'securityGroup:launchpad', + }) + ); + }); + + it('should maintain top position for most items when classic nav update is enabled', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseNavLinks.mockReturnValue([alertsNavLink]); + renderNav(); + expect(mockSolutionSideNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [ + expect.objectContaining({ + id: SecurityPageName.alerts, + position: 'top', + }), + ], }) ); }); From 67d6b15d6d2764d5a4518974cdaa077b5e8a588c Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Wed, 1 Apr 2026 18:03:57 +0200 Subject: [PATCH 05/14] cleanup tests --- .../security_side_nav/security_side_nav.test.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx index 5691cde542c21..624db3246260c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx @@ -307,17 +307,6 @@ describe('SecuritySideNav', () => { ); }); - it('should use splitButton interaction variant when classic nav update is enabled but serverless is present', () => { - mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); - useKibana().services.serverless = {}; - renderNav(); - expect(mockSolutionSideNav).toHaveBeenCalledWith( - expect.objectContaining({ - navLinkInteractionVariant: 'splitButton', - }) - ); - }); - it('should place administration item in footer when classic nav update is enabled', () => { mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); mockUseNavLinks.mockReturnValue([alertsNavLink, settingsNavLink]); @@ -342,7 +331,7 @@ describe('SecuritySideNav', () => { const lastCall = calls[calls.length - 1]; const items = lastCall[0].items; const administrationItemsInBody = items.filter( - (item: any) => item.id === SecurityPageName.administration && item.position !== 'bottom' + (item) => item.id === SecurityPageName.administration && item.position !== 'bottom' ); expect(administrationItemsInBody).toHaveLength(0); }); From 598cc8232f08a390f6c6a8fb97fcf5a15c81c31c Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Wed, 1 Apr 2026 20:05:23 +0200 Subject: [PATCH 06/14] fix --- .../breadcrumbs/use_breadcrumbs_nav.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.test.ts index 89231c1ff37e2..d451bc0c139e7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/breadcrumbs/use_breadcrumbs_nav.test.ts @@ -13,6 +13,7 @@ import type { LinkInfo, LinkItem } from '../../../links'; import { useBreadcrumbsNav } from './use_breadcrumbs_nav'; import type { BreadcrumbsNav } from '../../../breadcrumbs'; import * as kibanaLib from '../../../lib/kibana'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; jest.mock('../../../lib/kibana'); @@ -22,6 +23,9 @@ jest.mock('react-redux', () => ({ useDispatch: () => mockDispatch, })); +jest.mock('../../../hooks/use_experimental_features'); +const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; + const link1Id = 'link-1' as SecurityPageName; const link2Id = 'link-2' as SecurityPageName; const link3Id = 'link-3' as SecurityPageName; @@ -77,6 +81,7 @@ const landingBreadcrumb = { describe('useBreadcrumbsNav', () => { beforeEach(() => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); jest.clearAllMocks(); }); @@ -157,4 +162,30 @@ describe('useBreadcrumbsNav', () => { expect(mockDispatch).toHaveBeenCalled(); expect(reportEventMock).toHaveBeenCalled(); }); + + it('should use SecurityPageName.landing when isClassicNavUpdateEnabled is false', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + renderHook(useBreadcrumbsNav); + + const calls = (mockSecuritySolutionUrl as jest.Mock).mock.calls; + const landingBreadcrumbCall = calls.find( + (call) => call[0].deepLinkId === SecurityPageName.landing + ); + + expect(landingBreadcrumbCall).toBeDefined(); + }); + + it('should use SecurityPageName.launchpad when isClassicNavUpdateEnabled is true', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + + renderHook(useBreadcrumbsNav); + + const calls = (mockSecuritySolutionUrl as jest.Mock).mock.calls; + const launchpadBreadcrumbCall = calls.find( + (call) => call[0].deepLinkId === SecurityPageName.launchpad + ); + + expect(launchpadBreadcrumbCall).toBeDefined(); + }); }); From 6fdb6080971a91578091008b8af975f39ded375a Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Mon, 6 Apr 2026 10:17:16 +0200 Subject: [PATCH 07/14] update bundle limit for security_solution --- packages/kbn-optimizer/limits.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index b8b78d056ca74..cecf7c76052b1 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -163,7 +163,7 @@ pageLoadAssetSize: searchQueryRules: 6689 searchSynonyms: 6371 security: 79627 - securitySolution: 135301 + securitySolution: 157027 securitySolutionEss: 38689 securitySolutionServerless: 52082 serverless: 7412 From 8f9a24ea0367815d01daa0b57d48ede3d6b0d6aa Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:45:28 +0000 Subject: [PATCH 08/14] Changes from node scripts/eslint_all_files --no-cache --fix --- .../plugins/security_solution/common/experimental_features.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts index 0464bab119fa2..fb0e2e6a0ad84 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts @@ -257,7 +257,7 @@ export const allowedExperimentalValues = Object.freeze({ * Release: 9.4 */ prebuiltRulesDeprecationUIEnabled: false, - + /** * Classic chrome only: refreshed Security side nav (Launchpad, Manage footer; unified row + panel behavior). */ From ef71199721bfc909dbe8dd96f1a4198fab1d7cbd Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Tue, 7 Apr 2026 08:53:48 +0200 Subject: [PATCH 09/14] fix --- .../components/navigation/security_side_nav/categories.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts index b99234840c829..876d2fb9724f5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts @@ -42,7 +42,8 @@ export const getNavCategories = ( ]; return securityClassicNavUpdate - ? [ + ? categories + : [ ...categories, { type: LinkCategoryType.separator, @@ -52,6 +53,5 @@ export const getNavCategories = ( SecurityPageName.siemMigrationsLanding, ], }, - ] - : categories; + ]; }; From e6cfa3de9ff588a6a00edd55c482826e3d418967 Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Tue, 7 Apr 2026 09:52:50 +0200 Subject: [PATCH 10/14] add test for feature flagged nav version --- .../rules/translated_rules_page.cy.ts | 145 +++++++++++------- .../cypress/screens/security_header.ts | 9 ++ .../cypress/tasks/siem_migrations.ts | 14 +- 3 files changed, 111 insertions(+), 57 deletions(-) diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page.cy.ts index df1111be0be60..d962d4bbdb6db 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page.cy.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page.cy.ts @@ -28,76 +28,111 @@ import { import { GET_STARTED_URL } from '../../../../urls/navigation'; import { role } from '../common/role'; -// TODO: https://github.com/elastic/kibana/issues/228940 remove @skipInServerlessMKI tag when privileges issue is fixed -describe( - 'Rule Migrations - Translated Rules Page', - { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, - () => { - before(() => { - role.setup(); +const runTranslatedRulesPageTests = (isClassicNavUpdateEnabled: boolean) => { + beforeEach(() => { + deleteConnectors(); + cy.task('esArchiverLoad', { + archiveName: 'siem_migrations/rules', }); - beforeEach(() => { - deleteConnectors(); - cy.task('esArchiverLoad', { - archiveName: 'siem_migrations/rules', - }); + cy.task('esArchiverLoad', { + archiveName: 'siem_migrations/rule_migrations', + }); - cy.task('esArchiverLoad', { - archiveName: 'siem_migrations/rule_migrations', - }); + createBedrockConnector(); - createBedrockConnector(); + role.login(); + visit(GET_STARTED_URL); + selectMigrationConnector(); + navigateToTranslatedRulesPage(isClassicNavUpdateEnabled); + }); - role.login(); - visit(GET_STARTED_URL); - selectMigrationConnector(); - navigateToTranslatedRulesPage(); + afterEach(() => { + cy.task('esArchiverUnload', { + archiveName: 'siem_migrations/rules', }); - after(() => { - role.teardown(); + cy.task('esArchiverUnload', { + archiveName: 'siem_migrations/rule_migrations', + }); + }); - cy.task('esArchiverUnload', { - archiveName: 'siem_migrations/rules', - }); + it('should be able to see the result of the completed migration', () => { + cy.get(TRANSLATED_RULES_RESULT_TABLE.ROWS).should('have.length', 6); + cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('partial')).should('have.length', 4); + cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('full')).should('have.length', 1); + cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('failed')).should('have.length', 1); + }); - cy.task('esArchiverUnload', { - archiveName: 'siem_migrations/rule_migrations', - }); - }); - it('should be able to see the result of the completed migration', () => { - cy.get(TRANSLATED_RULES_RESULT_TABLE.ROWS).should('have.length', 6); - cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('partial')).should('have.length', 4); - cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('full')).should('have.length', 1); - cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('failed')).should('have.length', 1); + it('should be able to edit a rule with partial translation', () => { + cy.get(TRANSLATED_RULES_RESULT_TABLE.TABLE).should('be.visible'); + + editTranslatedRuleByRow(1); + const newESQLQuery = 'FROM auditbeat-* metadata _id, _version, _index'; + updateTranslatedRuleQuery(newESQLQuery); + saveUpdatedTranslatedRuleQuery(); + + cy.get(TRANSLATED_RULE_EDIT_BTN).should('be.visible'); + cy.get(TRANSLATED_RULE_QUERY_VIEWER).should('contain.text', newESQLQuery); + cy.get(TRANSLATED_RULE_RESULT_BADGE).should('have.text', 'Translated'); + }); + + it('should be able to reprocess a failed Rule', () => { + cy.intercept({ + url: '**/start', + }).as('reprocessFailedRules'); + openReprocessDialog(); + reprocessWithoutPrebuiltRulesMatching(); + cy.wait('@reprocessFailedRules') + .its('request.body.settings') + .should('have.property', 'skip_prebuilt_rules_matching', true); + cy.get(RULE_MIGRATION_PROGRESS_BAR).should('be.visible'); + cy.get(RULE_MIGRATION_PROGRESS_BAR_TEXT).should('contain.text', '83%'); + }); +}; + +// TODO: https://github.com/elastic/kibana/issues/228940 remove @skipInServerlessMKI tag when privileges issue is fixed +describe( + 'Rule Migrations - Translated Rules Page (securityClassicNavUpdate disabled)', + { + tags: ['@ess', '@serverless', '@skipInServerlessMKI'], + }, + () => { + before(() => { + role.setup(); }); - it('should be able to edit a rule with partial translation', () => { - cy.get(TRANSLATED_RULES_RESULT_TABLE.TABLE).should('be.visible'); + after(() => { + role.teardown(); + }); - editTranslatedRuleByRow(1); - const newESQLQuery = 'FROM auditbeat-* metadata _id, _version, _index'; - updateTranslatedRuleQuery(newESQLQuery); - saveUpdatedTranslatedRuleQuery(); + runTranslatedRulesPageTests(false); + } +); - cy.get(TRANSLATED_RULE_EDIT_BTN).should('be.visible'); - cy.get(TRANSLATED_RULE_QUERY_VIEWER).should('contain.text', newESQLQuery); - cy.get(TRANSLATED_RULE_RESULT_BADGE).should('have.text', 'Translated'); +describe( + 'Rule Migrations - Translated Rules Page (securityClassicNavUpdate enabled)', + { + tags: ['@ess', '@serverless', '@skipInServerlessMKI'], + env: { + ftrConfig: { + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'securityClassicNavUpdate', + ])}`, + ], + }, + }, + }, + () => { + before(() => { + role.setup(); }); - it('should be able to reprocess a failed Rule', () => { - cy.intercept({ - url: '**/start', - }).as('reprocessFailedRules'); - openReprocessDialog(); - // cy.wait(50000); - reprocessWithoutPrebuiltRulesMatching(); - cy.wait('@reprocessFailedRules') - .its('request.body.settings') - .should('have.property', 'skip_prebuilt_rules_matching', true); - cy.get(RULE_MIGRATION_PROGRESS_BAR).should('be.visible'); - cy.get(RULE_MIGRATION_PROGRESS_BAR_TEXT).should('contain.text', '83%'); + after(() => { + role.teardown(); }); + + runTranslatedRulesPageTests(true); } ); diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/security_header.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/security_header.ts index 2172b80afb3cf..18c7aa7833e7b 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/security_header.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/security_header.ts @@ -99,6 +99,15 @@ export const TRANSLATED_RULES_PAGE = Cypress.env('IS_SERVERLESS') ? getDataTestSubjectSelectorMatch('nav-item-id-siem_migrations-rules') : getDataTestSubjectSelector('solutionSideNavPanelLink-siem_migrations-rules'); +export const LAUNCHPAD_PANEL_BTN = getDataTestSubjectSelector( + 'solutionSideNavItemLink-securityGroup:launchpad' +); + +export const LAUNCHPAD_TRANSLATED_RULES_PAGE = getDataTestSubjectSelector( + 'solutionSideNavPanelLink-siem_migrations-rules' +); + +// not used anywhere (added in https://github.com/elastic/kibana/pull/238116/files) export const TRANSLATED_DASHBOARDS_PAGE = getDataTestSubjectSelector( 'solutionSideNavPanelLink-siem_migrations-dashboards' ); diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/siem_migrations.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/siem_migrations.ts index add185fb0c939..6f8cfa0de3c12 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/siem_migrations.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/siem_migrations.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { MIGRATIONS_PANEL_BTN, TRANSLATED_RULES_PAGE } from '../screens/security_header'; +import { + MIGRATIONS_PANEL_BTN, + TRANSLATED_RULES_PAGE, + LAUNCHPAD_PANEL_BTN, + LAUNCHPAD_TRANSLATED_RULES_PAGE, +} from '../screens/security_header'; import { FOOTER_LAUNCHPAD, openNavigationPanel, @@ -15,12 +20,17 @@ import { import * as SELECTORS from '../screens/siem_migrations'; import { bedrockConnectorAPIPayload } from './api_calls/connectors'; -export const navigateToTranslatedRulesPage = () => { +export const navigateToTranslatedRulesPage = (isClassicNavUpdateEnabled: boolean) => { if (Cypress.env('IS_SERVERLESS')) { openNavigationPanel(RULES_PANEL_BTN_SERVERLESS); cy.get(FOOTER_LAUNCHPAD).click(); cy.get(TRANSLATED_RULES_PAGE_SERVERLESS).click(); + } else if (isClassicNavUpdateEnabled) { + // ESS with classic nav: navigate through Launchpad group to reach Migrations + openNavigationPanel(LAUNCHPAD_PANEL_BTN); + cy.get(LAUNCHPAD_TRANSLATED_RULES_PAGE).click(); } else { + // ESS without classic nav: navigate directly to Migrations in the side nav openNavigationPanel(MIGRATIONS_PANEL_BTN); cy.get(TRANSLATED_RULES_PAGE).click(); } From dc9f1d450024f7ba7a690920a8db09fea3393e38 Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Tue, 7 Apr 2026 10:33:39 +0200 Subject: [PATCH 11/14] fix bad merge refs 90857a87367422427b659e02a16853a8ad98d5b2 --- .../plugins/security_solution/public/app/links/app_links.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts b/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts index 611d0a5f4318f..670e84cc48d38 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts @@ -27,7 +27,6 @@ import { getManagementFilteredLinks, links as managementLinks } from '../../mana import { exploreLinks } from '../../explore/links'; import { launchPadLinks, onboardingLinks } from '../../onboarding/links'; import { findingsLinks } from '../../cloud_security_posture/links'; -import type { ExperimentalFeatures } from '../../../common/experimental_features'; import type { StartPlugins } from '../../types'; import { dashboardsLinks } from '../../dashboards/links'; import { entityAnalyticsLinks } from '../../entity_analytics/links'; From 735473b52fc9204bf5a5f9ac8ac4b2e238f18f04 Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Tue, 7 Apr 2026 11:29:37 +0200 Subject: [PATCH 12/14] redo test files refs e6cfa3de9ff588a6a00edd55c482826e3d418967 --- .../rules/translated_rules_page.cy.ts | 145 +++++++----------- .../rules/translated_rules_page_new_nav.cy.ts | 116 ++++++++++++++ 2 files changed, 171 insertions(+), 90 deletions(-) create mode 100644 x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page_new_nav.cy.ts diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page.cy.ts index d962d4bbdb6db..11a2de438c1af 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page.cy.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page.cy.ts @@ -28,111 +28,76 @@ import { import { GET_STARTED_URL } from '../../../../urls/navigation'; import { role } from '../common/role'; -const runTranslatedRulesPageTests = (isClassicNavUpdateEnabled: boolean) => { - beforeEach(() => { - deleteConnectors(); - cy.task('esArchiverLoad', { - archiveName: 'siem_migrations/rules', - }); - - cy.task('esArchiverLoad', { - archiveName: 'siem_migrations/rule_migrations', +// TODO: https://github.com/elastic/kibana/issues/228940 remove @skipInServerlessMKI tag when privileges issue is fixed +describe( + 'Rule Migrations - Translated Rules Page', + { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, + () => { + before(() => { + role.setup(); }); - createBedrockConnector(); + beforeEach(() => { + deleteConnectors(); + cy.task('esArchiverLoad', { + archiveName: 'siem_migrations/rules', + }); - role.login(); - visit(GET_STARTED_URL); - selectMigrationConnector(); - navigateToTranslatedRulesPage(isClassicNavUpdateEnabled); - }); + cy.task('esArchiverLoad', { + archiveName: 'siem_migrations/rule_migrations', + }); - afterEach(() => { - cy.task('esArchiverUnload', { - archiveName: 'siem_migrations/rules', - }); + createBedrockConnector(); - cy.task('esArchiverUnload', { - archiveName: 'siem_migrations/rule_migrations', + role.login(); + visit(`${GET_STARTED_URL}/siem_migrations`); + selectMigrationConnector(); + navigateToTranslatedRulesPage(false); }); - }); - it('should be able to see the result of the completed migration', () => { - cy.get(TRANSLATED_RULES_RESULT_TABLE.ROWS).should('have.length', 6); - cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('partial')).should('have.length', 4); - cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('full')).should('have.length', 1); - cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('failed')).should('have.length', 1); - }); - - it('should be able to edit a rule with partial translation', () => { - cy.get(TRANSLATED_RULES_RESULT_TABLE.TABLE).should('be.visible'); - - editTranslatedRuleByRow(1); - const newESQLQuery = 'FROM auditbeat-* metadata _id, _version, _index'; - updateTranslatedRuleQuery(newESQLQuery); - saveUpdatedTranslatedRuleQuery(); - - cy.get(TRANSLATED_RULE_EDIT_BTN).should('be.visible'); - cy.get(TRANSLATED_RULE_QUERY_VIEWER).should('contain.text', newESQLQuery); - cy.get(TRANSLATED_RULE_RESULT_BADGE).should('have.text', 'Translated'); - }); + after(() => { + role.teardown(); - it('should be able to reprocess a failed Rule', () => { - cy.intercept({ - url: '**/start', - }).as('reprocessFailedRules'); - openReprocessDialog(); - reprocessWithoutPrebuiltRulesMatching(); - cy.wait('@reprocessFailedRules') - .its('request.body.settings') - .should('have.property', 'skip_prebuilt_rules_matching', true); - cy.get(RULE_MIGRATION_PROGRESS_BAR).should('be.visible'); - cy.get(RULE_MIGRATION_PROGRESS_BAR_TEXT).should('contain.text', '83%'); - }); -}; + cy.task('esArchiverUnload', { + archiveName: 'siem_migrations/rules', + }); -// TODO: https://github.com/elastic/kibana/issues/228940 remove @skipInServerlessMKI tag when privileges issue is fixed -describe( - 'Rule Migrations - Translated Rules Page (securityClassicNavUpdate disabled)', - { - tags: ['@ess', '@serverless', '@skipInServerlessMKI'], - }, - () => { - before(() => { - role.setup(); + cy.task('esArchiverUnload', { + archiveName: 'siem_migrations/rule_migrations', + }); }); - - after(() => { - role.teardown(); + it('should be able to see the result of the completed migration', () => { + cy.get(TRANSLATED_RULES_RESULT_TABLE.ROWS).should('have.length', 6); + cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('partial')).should('have.length', 4); + cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('full')).should('have.length', 1); + cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('failed')).should('have.length', 1); }); - runTranslatedRulesPageTests(false); - } -); + it('should be able to edit a rule with partial translation', () => { + cy.get(TRANSLATED_RULES_RESULT_TABLE.TABLE).should('be.visible'); -describe( - 'Rule Migrations - Translated Rules Page (securityClassicNavUpdate enabled)', - { - tags: ['@ess', '@serverless', '@skipInServerlessMKI'], - env: { - ftrConfig: { - kbnServerArgs: [ - `--xpack.securitySolution.enableExperimental=${JSON.stringify([ - 'securityClassicNavUpdate', - ])}`, - ], - }, - }, - }, - () => { - before(() => { - role.setup(); - }); + editTranslatedRuleByRow(1); + const newESQLQuery = 'FROM auditbeat-* metadata _id, _version, _index'; + updateTranslatedRuleQuery(newESQLQuery); + saveUpdatedTranslatedRuleQuery(); - after(() => { - role.teardown(); + cy.get(TRANSLATED_RULE_EDIT_BTN).should('be.visible'); + cy.get(TRANSLATED_RULE_QUERY_VIEWER).should('contain.text', newESQLQuery); + cy.get(TRANSLATED_RULE_RESULT_BADGE).should('have.text', 'Translated'); }); - runTranslatedRulesPageTests(true); + it('should be able to reprocess a failed Rule', () => { + cy.intercept({ + url: '**/start', + }).as('reprocessFailedRules'); + openReprocessDialog(); + // cy.wait(50000); + reprocessWithoutPrebuiltRulesMatching(); + cy.wait('@reprocessFailedRules') + .its('request.body.settings') + .should('have.property', 'skip_prebuilt_rules_matching', true); + cy.get(RULE_MIGRATION_PROGRESS_BAR).should('be.visible'); + cy.get(RULE_MIGRATION_PROGRESS_BAR_TEXT).should('contain.text', '83%'); + }); } ); diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page_new_nav.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page_new_nav.cy.ts new file mode 100644 index 0000000000000..ea5256c49d620 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page_new_nav.cy.ts @@ -0,0 +1,116 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + RULE_MIGRATION_PROGRESS_BAR, + RULE_MIGRATION_PROGRESS_BAR_TEXT, + TRANSLATED_RULE_EDIT_BTN, + TRANSLATED_RULE_QUERY_VIEWER, + TRANSLATED_RULE_RESULT_BADGE, + TRANSLATED_RULES_RESULT_TABLE, +} from '../../../../screens/siem_migrations'; +import { deleteConnectors } from '../../../../tasks/api_calls/common'; +import { createBedrockConnector } from '../../../../tasks/api_calls/connectors'; +import { visit } from '../../../../tasks/navigation'; +import { + editTranslatedRuleByRow, + saveUpdatedTranslatedRuleQuery, + selectMigrationConnector, + updateTranslatedRuleQuery, + navigateToTranslatedRulesPage, + openReprocessDialog, + reprocessWithoutPrebuiltRulesMatching, +} from '../../../../tasks/siem_migrations'; +import { GET_STARTED_URL } from '../../../../urls/navigation'; +import { role } from '../common/role'; + +// TODO: https://github.com/elastic/kibana/issues/228940 remove @skipInServerlessMKI tag when privileges issue is fixed +describe( + 'Rule Migrations - Translated Rules Page (securityClassicNavUpdate enabled)', + { + tags: ['@ess', '@serverless', '@skipInServerlessMKI'], + env: { + ftrConfig: { + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'securityClassicNavUpdate', + ])}`, + ], + }, + }, + }, + () => { + before(() => { + role.setup(); + }); + + after(() => { + role.teardown(); + }); + + beforeEach(() => { + deleteConnectors(); + cy.task('esArchiverLoad', { + archiveName: 'siem_migrations/rules', + }); + + cy.task('esArchiverLoad', { + archiveName: 'siem_migrations/rule_migrations', + }); + + createBedrockConnector(); + + role.login(); + visit(GET_STARTED_URL); + selectMigrationConnector(); + navigateToTranslatedRulesPage(true); + }); + + afterEach(() => { + cy.task('esArchiverUnload', { + archiveName: 'siem_migrations/rules', + }); + + cy.task('esArchiverUnload', { + archiveName: 'siem_migrations/rule_migrations', + }); + }); + + it('should be able to see the result of the completed migration', () => { + cy.get(TRANSLATED_RULES_RESULT_TABLE.ROWS).should('have.length', 6); + cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('partial')).should('have.length', 4); + cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('full')).should('have.length', 1); + cy.get(TRANSLATED_RULES_RESULT_TABLE.STATUS('failed')).should('have.length', 1); + }); + + it('should be able to edit a rule with partial translation', () => { + cy.get(TRANSLATED_RULES_RESULT_TABLE.TABLE).should('be.visible'); + + editTranslatedRuleByRow(1); + const newESQLQuery = 'FROM auditbeat-* metadata _id, _version, _index'; + updateTranslatedRuleQuery(newESQLQuery); + saveUpdatedTranslatedRuleQuery(); + + cy.get(TRANSLATED_RULE_EDIT_BTN).should('be.visible'); + cy.get(TRANSLATED_RULE_QUERY_VIEWER).should('contain.text', newESQLQuery); + cy.get(TRANSLATED_RULE_RESULT_BADGE).should('have.text', 'Translated'); + }); + + it('should be able to reprocess a failed Rule', () => { + cy.intercept({ + url: '**/start', + }).as('reprocessFailedRules'); + openReprocessDialog(); + reprocessWithoutPrebuiltRulesMatching(); + cy.wait('@reprocessFailedRules') + .its('request.body.settings') + .should('have.property', 'skip_prebuilt_rules_matching', true); + cy.get(RULE_MIGRATION_PROGRESS_BAR).should('be.visible'); + cy.get(RULE_MIGRATION_PROGRESS_BAR_TEXT).should('contain.text', '83%'); + }); + } +); From 333665bf0735a398dd32c68e06824ea023101eca Mon Sep 17 00:00:00 2001 From: Jatin Kathuria Date: Tue, 7 Apr 2026 14:04:33 +0200 Subject: [PATCH 13/14] fix: path --- .../siem_migrations/rules/translated_rules_page_new_nav.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page_new_nav.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page_new_nav.cy.ts index ea5256c49d620..c40e646f42876 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page_new_nav.cy.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/investigations/siem_migrations/rules/translated_rules_page_new_nav.cy.ts @@ -65,7 +65,7 @@ describe( createBedrockConnector(); role.login(); - visit(GET_STARTED_URL); + visit(`${GET_STARTED_URL}/siem_migrations`); selectMigrationConnector(); navigateToTranslatedRulesPage(true); }); From c767b39b370033e861c182c500c4d67b2ecf4c52 Mon Sep 17 00:00:00 2001 From: ashokaditya Date: Tue, 7 Apr 2026 20:05:10 +0200 Subject: [PATCH 14/14] add `manage automatic migrations` to launchpad --- .../navigation/security_side_nav/security_side_nav.tsx | 10 ++++++---- .../security_solution/public/onboarding/links.ts | 7 ++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx index 6c8bf218d5e4c..a72a3dffdf7ea 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -62,7 +62,7 @@ const LAUNCHPAD_PAGES: ReadonlySet = new Set([ SecurityPageName.landing, SecurityPageName.siemReadiness, SecurityPageName.aiValue, - SecurityPageName.siemMigrationsLanding, + SecurityPageName.siemMigrationsManage, SecurityPageName.siemMigrationsRules, SecurityPageName.siemMigrationsDashboards, ]); @@ -136,9 +136,11 @@ const useSolutionSideNavItems = (isClassicNavUpdateLayout: boolean) => { ); const areMigrationLinks = (id: SecurityPageName) => - [SecurityPageName.siemMigrationsRules, SecurityPageName.siemMigrationsDashboards].includes( - id - ); + [ + SecurityPageName.siemMigrationsManage, + SecurityPageName.siemMigrationsRules, + SecurityPageName.siemMigrationsDashboards, + ].includes(id); const { launchpadPanelItems, launchpadCategories } = CLASSIC_LAUNCHPAD_PANEL_LINK_ENTRIES.reduce<{ diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts b/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts index 10ebfa86d5df0..719bcf787cec1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/links.ts @@ -53,7 +53,11 @@ export const launchPadLinks: LinkItem = { label: i18n.translate('xpack.securitySolution.appLinks.category.migrations', { defaultMessage: 'Migrations', }), - linkIds: [SecurityPageName.siemMigrationsLanding, SecurityPageName.siemMigrationsDashboards], + linkIds: [ + SecurityPageName.siemMigrationsManage, + SecurityPageName.siemMigrationsRules, + SecurityPageName.siemMigrationsDashboards, + ], }, ], links: [onboardingLinks, aiValueLinks, siemMigrationsLinks, siemReadinessLinks], @@ -71,6 +75,7 @@ export const CLASSIC_LAUNCHPAD_PANEL_LINK_ENTRIES = Object.freeze([ { id: SecurityPageName.landing }, { id: SecurityPageName.siemReadiness }, { id: SecurityPageName.aiValue }, + { id: SecurityPageName.siemMigrationsManage }, { id: SecurityPageName.siemMigrationsRules }, { id: SecurityPageName.siemMigrationsDashboards }, ]);