Nav unified show timeline (#131811)

* Update useShowTimeline to work with new grouped navigation

* Fix bundle size

* Fix broken unit tests

* Please code review

* Fix create rules deepLink

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pablo Machado 2022-05-18 18:37:44 +02:00 committed by GitHub
parent e4a365a298
commit fa7df7983c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 195 additions and 86 deletions

View file

@ -108,6 +108,7 @@ export enum SecurityPageName {
overview = 'overview',
policies = 'policy',
rules = 'rules',
rulesCreate = 'rules-create',
timelines = 'timelines',
timelinesTemplates = 'timelines-templates',
trustedApps = 'trusted_apps',
@ -119,7 +120,7 @@ export enum SecurityPageName {
sessions = 'sessions',
usersEvents = 'users-events',
usersExternalAlerts = 'users-external_alerts',
threatHuntingLanding = 'threat-hunting',
threatHuntingLanding = 'threat_hunting',
dashboardsLanding = 'dashboards',
}
@ -134,6 +135,7 @@ export const DETECTION_RESPONSE_PATH = '/detection_response' as const;
export const DETECTIONS_PATH = '/detections' as const;
export const ALERTS_PATH = '/alerts' as const;
export const RULES_PATH = '/rules' as const;
export const RULES_CREATE_PATH = `${RULES_PATH}/create` as const;
export const EXCEPTIONS_PATH = '/exceptions' as const;
export const HOSTS_PATH = '/hosts' as const;
export const USERS_PATH = '/users' as const;

View file

@ -35,6 +35,7 @@ import {
GETTING_STARTED,
THREAT_HUNTING,
DASHBOARDS,
CREATE_NEW_RULE,
} from '../translations';
import {
OVERVIEW_PATH,
@ -59,6 +60,7 @@ import {
THREAT_HUNTING_PATH,
DASHBOARDS_PATH,
MANAGE_PATH,
RULES_CREATE_PATH,
} from '../../../common/constants';
import { ExperimentalFeatures } from '../../../common/experimental_features';
@ -183,6 +185,15 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
}),
],
searchable: true,
deepLinks: [
{
id: SecurityPageName.rulesCreate,
title: CREATE_NEW_RULE,
path: RULES_CREATE_PATH,
navLinkStatus: AppNavLinkStatus.hidden,
searchable: false,
},
],
},
{
id: SecurityPageName.exceptions,

View file

@ -110,6 +110,10 @@ export const BLOCKLIST = i18n.translate('xpack.securitySolution.navigation.block
defaultMessage: 'Blocklist',
});
export const CREATE_NEW_RULE = i18n.translate('xpack.securitySolution.navigation.newRuleTitle', {
defaultMessage: 'Create new rule',
});
export const GO_TO_DOCUMENTATION = i18n.translate(
'xpack.securitySolution.goToDocumentationButton',
{

View file

@ -21,9 +21,11 @@ export const getCasesLinkItems = (): LinkItem => {
[SecurityPageName.caseConfigure]: {
features: [FEATURE.casesCrud],
licenseType: 'gold',
hideTimeline: true,
},
[SecurityPageName.caseCreate]: {
features: [FEATURE.casesCrud],
hideTimeline: true,
},
},
});

View file

@ -11,8 +11,6 @@ export const getDetectionEngineUrl = (search?: string) => `${appendSearch(search
export const getRulesUrl = (search?: string) => `${appendSearch(search)}`;
export const getCreateRuleUrl = (search?: string) => `/create${appendSearch(search)}`;
export const getRuleDetailsUrl = (detailName: string, search?: string) =>
`/id/${detailName}${appendSearch(search)}`;

View file

@ -83,7 +83,9 @@ const isAdminRoutes = (spyState: RouteSpyState): spyState is AdministrationRoute
spyState != null && spyState.pageName === SecurityPageName.administration;
const isRulesRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState =>
spyState != null && spyState.pageName === SecurityPageName.rules;
spyState != null &&
(spyState.pageName === SecurityPageName.rules ||
spyState.pageName === SecurityPageName.rulesCreate);
// eslint-disable-next-line complexity
export const getBreadcrumbsForRoute = (

View file

@ -30,6 +30,7 @@ import { SourcererScopeName, SourcererUrlState } from '../../store/sourcerer/mod
export const isDetectionsPages = (pageName: string) =>
pageName === SecurityPageName.alerts ||
pageName === SecurityPageName.rules ||
pageName === SecurityPageName.rulesCreate ||
pageName === SecurityPageName.exceptions;
export const decodeRisonUrlState = <T>(value: string | undefined): T | null => {
@ -103,7 +104,7 @@ export const getUrlType = (pageName: string): UrlStateType => {
return 'network';
} else if (pageName === SecurityPageName.alerts) {
return 'alerts';
} else if (pageName === SecurityPageName.rules) {
} else if (pageName === SecurityPageName.rules || pageName === SecurityPageName.rulesCreate) {
return 'rules';
} else if (pageName === SecurityPageName.exceptions) {
return 'exceptions';

View file

@ -33,6 +33,8 @@ export const appLinks: Readonly<LinkItem[]> = Object.freeze([
}),
],
links: [hostsLinks, networkLinks, usersLinks],
skipUrlState: true,
hideTimeline: true,
},
timelinesLinks,
getCasesLinkItems(),

View file

@ -98,9 +98,11 @@ const threatHuntingLinkInfo = {
features: ['siem.show'],
globalNavEnabled: false,
globalSearchKeywords: ['Threat hunting'],
id: 'threat-hunting',
id: 'threat_hunting',
path: '/threat_hunting',
title: 'Threat Hunting',
hideTimeline: true,
skipUrlState: true,
};
const hostsLinkInfo = {

View file

@ -155,7 +155,6 @@ const getNormalizedLinks = (
* Normalized indexed version of the global `links` array, referencing the parent by id, instead of having nested links children
*/
const normalizedLinks: Readonly<NormalizedLinks> = Object.freeze(getNormalizedLinks(appLinks));
/**
* Returns the `NormalizedLink` from a link id parameter.
* The object reference is frozen to make sure it is not mutated by the caller.
@ -193,3 +192,7 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => {
export const needsUrlState = (id: SecurityPageName): boolean => {
return !getNormalizedLink(id).skipUrlState;
};
export const getLinksWithHiddenTimeline = (): LinkInfo[] => {
return Object.values(normalizedLinks).filter((link) => link.hideTimeline);
};

View file

@ -58,6 +58,7 @@ export interface LinkItem {
links?: LinkItem[];
path: string;
skipUrlState?: boolean; // defaults to false
hideTimeline?: boolean; // defaults to false
title: string;
}

View file

@ -17,40 +17,96 @@ jest.mock('react-router-dom', () => {
};
});
const mockedUseIsGroupedNavigationEnabled = jest.fn();
jest.mock('../../components/navigation/helpers', () => ({
useIsGroupedNavigationEnabled: () => mockedUseIsGroupedNavigationEnabled(),
}));
describe('use show timeline', () => {
it('shows timeline for routes on default', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([true]);
describe('useIsGroupedNavigationEnabled false', () => {
beforeAll(() => {
mockedUseIsGroupedNavigationEnabled.mockReturnValue(false);
});
it('shows timeline for routes on default', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([true]);
});
});
it('hides timeline for blacklist routes', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/rules/create' });
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([false]);
});
});
it('shows timeline for partial blacklist routes', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/rules' });
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([true]);
});
});
it('hides timeline for sub blacklist routes', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/administration/policy' });
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([false]);
});
});
});
it('hides timeline for blacklist routes', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/rules/create' });
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([false]);
describe('useIsGroupedNavigationEnabled true', () => {
beforeAll(() => {
mockedUseIsGroupedNavigationEnabled.mockReturnValue(true);
});
});
it('shows timeline for partial blacklist routes', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/rules' });
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([true]);
it('shows timeline for routes on default', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([true]);
});
});
});
it('hides timeline for sub blacklist routes', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/administration/policy' });
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([false]);
it('hides timeline for blacklist routes', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/rules/create' });
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([false]);
});
});
it('shows timeline for partial blacklist routes', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/rules' });
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([true]);
});
});
it('hides timeline for sub blacklist routes', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/administration/policy' });
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useShowTimeline());
await waitForNextUpdate();
const showTimeline = result.current;
expect(showTimeline).toEqual([false]);
});
});
});
});

View file

@ -8,7 +8,10 @@
import { useState, useEffect } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
const HIDDEN_TIMELINE_ROUTES: readonly string[] = [
import { getLinksWithHiddenTimeline } from '../../links';
import { useIsGroupedNavigationEnabled } from '../../components/navigation/helpers';
const DEPRECATED_HIDDEN_TIMELINE_ROUTES: readonly string[] = [
`/cases/configure`,
'/administration',
'/rules/create',
@ -18,17 +21,27 @@ const HIDDEN_TIMELINE_ROUTES: readonly string[] = [
'/manage',
];
const isHiddenTimelinePath = (currentPath: string): boolean => {
return !!HIDDEN_TIMELINE_ROUTES.find((route) => matchPath(currentPath, route));
const isTimelineHidden = (currentPath: string, isGroupedNavigationEnabled: boolean): boolean => {
const groupLinksWithHiddenTimelinePaths = getLinksWithHiddenTimeline().map((l) => l.path);
const hiddenTimelineRoutes = isGroupedNavigationEnabled
? groupLinksWithHiddenTimelinePaths
: DEPRECATED_HIDDEN_TIMELINE_ROUTES;
return !!hiddenTimelineRoutes.find((route) => matchPath(currentPath, route));
};
export const useShowTimeline = () => {
const isGroupedNavigationEnabled = useIsGroupedNavigationEnabled();
const { pathname } = useLocation();
const [showTimeline, setShowTimeline] = useState(!isHiddenTimelinePath(pathname));
const [showTimeline, setShowTimeline] = useState(
!isTimelineHidden(pathname, isGroupedNavigationEnabled)
);
useEffect(() => {
setShowTimeline(!isHiddenTimelinePath(pathname));
}, [pathname]);
setShowTimeline(!isTimelineHidden(pathname, isGroupedNavigationEnabled));
}, [pathname, isGroupedNavigationEnabled]);
return [showTimeline];
};

View file

@ -161,9 +161,9 @@ describe('LoadPrebuiltRulesAndTemplatesButton', () => {
await waitFor(() => {
wrapper.update();
expect(
wrapper.find('[data-test-subj="load-prebuilt-rules"] button').props().disabled
).toEqual(true);
expect(wrapper.find('button[data-test-subj="load-prebuilt-rules"]').props().disabled).toEqual(
true
);
});
});
});

View file

@ -9,14 +9,11 @@ import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { memo, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { getCreateRuleUrl } from '../../../../common/components/link_to/redirect_to_detection_engine';
import * as i18n from './translations';
import { LinkButton } from '../../../../common/components/links';
import { SecuritySolutionLinkButton } from '../../../../common/components/links';
import { SecurityPageName } from '../../../../app/types';
import { useFormatUrl } from '../../../../common/components/link_to';
import { usePrePackagedRules } from '../../../containers/detection_engine/rules';
import { useUserData } from '../../user_info';
import { useNavigateTo } from '../../../../common/lib/kibana/hooks';
const EmptyPrompt = styled(EuiEmptyPrompt)`
align-self: center; /* Corrects horizontal centering in IE11 */
@ -38,16 +35,6 @@ const PrePackagedRulesPromptComponent: React.FC<PrePackagedRulesPromptProps> = (
const handlePreBuiltCreation = useCallback(() => {
createPrePackagedRules();
}, [createPrePackagedRules]);
const { formatUrl } = useFormatUrl(SecurityPageName.rules);
const { navigateTo } = useNavigateTo();
const goToCreateRule = useCallback(
(ev) => {
ev.preventDefault();
navigateTo({ deepLinkId: SecurityPageName.rules, path: getCreateRuleUrl() });
},
[navigateTo]
);
const [{ isSignalIndexExists, isAuthenticated, hasEncryptionKey, canUserCRUD, hasIndexWrite }] =
useUserData();
@ -80,14 +67,13 @@ const PrePackagedRulesPromptComponent: React.FC<PrePackagedRulesPromptProps> = (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>{loadPrebuiltRulesAndTemplatesButton}</EuiFlexItem>
<EuiFlexItem grow={false}>
<LinkButton
<SecuritySolutionLinkButton
isDisabled={!userHasPermissions}
onClick={goToCreateRule}
href={formatUrl(getCreateRuleUrl())}
iconType="plusInCircle"
deepLinkId={SecurityPageName.rulesCreate}
>
{i18n.CREATE_RULE_ACTION}
</LinkButton>
</SecuritySolutionLinkButton>
</EuiFlexItem>
</EuiFlexGroup>
}

View file

@ -439,7 +439,7 @@ const CreateRulePageComponent: React.FC = () => {
</EuiFlexGroup>
</SecuritySolutionPageWrapper>
<SpyRoute pageName={SecurityPageName.rules} />
<SpyRoute pageName={SecurityPageName.rulesCreate} />
</>
);
};

View file

@ -33,7 +33,25 @@ jest.mock('../../../containers/detection_engine/rules/use_find_rules_query');
jest.mock('../../../../common/components/link_to');
jest.mock('../../../components/user_info');
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/lib/kibana', () => {
const actual = jest.requireActual('../../../../common/lib/kibana');
return {
...actual,
useKibana: () => ({
services: {
...actual.useKibana().services,
application: {
navigateToApp: jest.fn(),
},
},
}),
useNavigation: () => ({
navigateTo: jest.fn(),
}),
};
});
jest.mock('../../../../common/components/toasters', () => {
const actual = jest.requireActual('../../../../common/components/toasters');
return {

View file

@ -10,10 +10,7 @@ import React, { useCallback, useMemo } from 'react';
import { usePrePackagedRules, importRules } from '../../../containers/detection_engine/rules';
import { useListsConfig } from '../../../containers/detection_engine/lists/use_lists_config';
import {
getDetectionEngineUrl,
getCreateRuleUrl,
} from '../../../../common/components/link_to/redirect_to_detection_engine';
import { getDetectionEngineUrl } from '../../../../common/components/link_to/redirect_to_detection_engine';
import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper';
import { SpyRoute } from '../../../../common/utils/route/spy_routes';
@ -30,8 +27,7 @@ import {
} from './helpers';
import * as i18n from './translations';
import { SecurityPageName } from '../../../../app/types';
import { LinkButton } from '../../../../common/components/links';
import { useFormatUrl } from '../../../../common/components/link_to';
import { SecuritySolutionLinkButton } from '../../../../common/components/links';
import { NeedAdminForUpdateRulesCallOut } from '../../../components/callouts/need_admin_for_update_callout';
import { MlJobCompatibilityCallout } from '../../../components/callouts/ml_job_compatibility_callout';
import { MissingPrivilegesCallOut } from '../../../components/callouts/missing_privileges_callout';
@ -96,7 +92,6 @@ const RulesPageComponent: React.FC = () => {
timelinesNotInstalled,
timelinesNotUpdated
);
const { formatUrl } = useFormatUrl(SecurityPageName.rules);
const handleCreatePrePackagedRules = useCallback(async () => {
if (createPrePackagedRules != null) {
@ -113,14 +108,6 @@ const RulesPageComponent: React.FC = () => {
}
}, [refetchPrePackagedRulesStatus]);
const goToNewRule = useCallback(
(ev) => {
ev.preventDefault();
navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.rules, path: getCreateRuleUrl() });
},
[navigateToApp]
);
const loadPrebuiltRulesAndTemplatesButton = useMemo(
() =>
getLoadPrebuiltRulesAndTemplatesButton({
@ -212,16 +199,15 @@ const RulesPageComponent: React.FC = () => {
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LinkButton
<SecuritySolutionLinkButton
data-test-subj="create-new-rule"
fill
onClick={goToNewRule}
href={formatUrl(getCreateRuleUrl())}
iconType="plusInCircle"
isDisabled={!userHasPermissions(canUserCRUD) || loading}
deepLinkId={SecurityPageName.rulesCreate}
>
{i18n.ADD_NEW_RULE}
</LinkButton>
</SecuritySolutionLinkButton>
</EuiFlexItem>
</EuiFlexGroup>
</HeaderPage>

View file

@ -12,14 +12,16 @@ import {
EVENT_FILTERS_PATH,
EXCEPTIONS_PATH,
HOST_ISOLATION_EXCEPTIONS_PATH,
MANAGEMENT_PATH,
MANAGE_PATH,
POLICIES_PATH,
RULES_CREATE_PATH,
RULES_PATH,
SecurityPageName,
TRUSTED_APPS_PATH,
} from '../../common/constants';
import {
BLOCKLIST,
CREATE_NEW_RULE,
ENDPOINTS,
EVENT_FILTERS,
EXCEPTIONS,
@ -44,8 +46,9 @@ import { IconTrustedApplications } from './icons/trusted_applications';
export const links: LinkItem = {
id: SecurityPageName.administration,
title: MANAGE,
path: MANAGEMENT_PATH,
path: MANAGE_PATH,
skipUrlState: true,
hideTimeline: true,
globalNavEnabled: false,
features: [FEATURE.general],
globalSearchKeywords: [
@ -71,6 +74,16 @@ export const links: LinkItem = {
}),
],
globalSearchEnabled: true,
links: [
{
id: SecurityPageName.rulesCreate,
title: CREATE_NEW_RULE,
path: RULES_CREATE_PATH,
globalNavEnabled: false,
skipUrlState: true,
hideTimeline: true,
},
],
},
{
id: SecurityPageName.exceptions,
@ -99,6 +112,7 @@ export const links: LinkItem = {
globalNavOrder: 9006,
path: ENDPOINTS_PATH,
skipUrlState: true,
hideTimeline: true,
},
{
id: SecurityPageName.policies,
@ -110,6 +124,7 @@ export const links: LinkItem = {
landingIcon: IconEndpointPolicies,
path: POLICIES_PATH,
skipUrlState: true,
hideTimeline: true,
experimentalKey: 'policyListEnabled',
},
{
@ -125,6 +140,7 @@ export const links: LinkItem = {
landingIcon: IconTrustedApplications,
path: TRUSTED_APPS_PATH,
skipUrlState: true,
hideTimeline: true,
},
{
id: SecurityPageName.eventFilters,
@ -135,6 +151,7 @@ export const links: LinkItem = {
landingIcon: IconEventFilters,
path: EVENT_FILTERS_PATH,
skipUrlState: true,
hideTimeline: true,
},
{
id: SecurityPageName.hostIsolationExceptions,
@ -145,6 +162,7 @@ export const links: LinkItem = {
landingIcon: IconHostIsolation,
path: HOST_ISOLATION_EXCEPTIONS_PATH,
skipUrlState: true,
hideTimeline: true,
},
{
id: SecurityPageName.blocklist,
@ -155,6 +173,7 @@ export const links: LinkItem = {
landingIcon: IconBlocklist,
path: BLOCKLIST_PATH,
skipUrlState: true,
hideTimeline: true,
},
],
};

View file

@ -48,6 +48,7 @@ export const gettingStartedLinks: LinkItem = {
}),
],
skipUrlState: true,
hideTimeline: true,
};
export const detectionResponseLinks: LinkItem = {
@ -81,4 +82,6 @@ export const dashboardsLandingLinks: LinkItem = {
}),
],
links: [overviewLinks, detectionResponseLinks],
skipUrlState: true,
hideTimeline: true,
};