[Security Solution] [Endpoint] Add back button as a secondary action in empty state (#122769)

* Adds back button as secondary action in empty state

* Make back button persistent when pushing new history and added unit tests for it

* Fix typo and removed unnecessary return

* Remove unnecessary return statements

* Move repeated code into a generic hook
This commit is contained in:
David Sánchez 2022-01-14 09:35:34 +01:00 committed by GitHub
parent 0e93af0aa7
commit 9257696c3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 259 additions and 85 deletions

View file

@ -0,0 +1,21 @@
/*
* 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 { useState, useEffect } from 'react';
import { ListPageRouteState } from '../../../common/endpoint/types';
export function useMemoizedRouteState(routeState: ListPageRouteState | undefined) {
const [memoizedRouteState, setMemoizedRouteState] = useState<ListPageRouteState | undefined>();
useEffect(() => {
// At some point we would like to check if the path has changed or not to keep this consistent across different pages
if (routeState && routeState.onBackButtonNavigateTo) {
setMemoizedRouteState(routeState);
}
}, [routeState]);
return memoizedRouteState;
}

View file

@ -63,15 +63,9 @@ export const AdministrationListPage: FC<AdministrationListPageProps & CommonProp
const getTestId = useTestIdGenerator(otherProps['data-test-subj']);
const pageHeader = useMemo(
() =>
hideHeader ? (
<EuiFlexGroup direction="column" gutterSize="none" alignItems="flexStart">
<EuiFlexItem grow={false}>
{headerBackComponent && <>{headerBackComponent}</>}
</EuiFlexItem>
</EuiFlexGroup>
) : (
return (
<div {...otherProps}>
{!hideHeader && (
<>
<EuiPageHeader
pageTitle={header}
@ -83,22 +77,7 @@ export const AdministrationListPage: FC<AdministrationListPageProps & CommonProp
/>
<EuiSpacer size="l" />
</>
),
[
actions,
description,
getTestId,
hasBottomBorder,
header,
headerBackComponent,
hideHeader,
restrictWidth,
]
);
return (
<div {...otherProps}>
{pageHeader}
)}
<EuiPageContent
hasBorder={false}

View file

@ -0,0 +1,39 @@
/*
* 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 React, { memo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { CommonProps, EuiButtonEmpty } from '@elastic/eui';
import { ListPageRouteState } from '../../../../common/endpoint/types';
import { useNavigateToAppEventHandler } from '../../../common/hooks/endpoint/use_navigate_to_app_event_handler';
export type BackToExternalAppSecondaryButtonProps = CommonProps & ListPageRouteState;
export const BackToExternalAppSecondaryButton = memo<BackToExternalAppSecondaryButtonProps>(
({ backButtonLabel, backButtonUrl, onBackButtonNavigateTo, ...commonProps }) => {
const handleBackOnClick = useNavigateToAppEventHandler(...onBackButtonNavigateTo);
return (
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiButtonEmpty
{...commonProps}
data-test-subj="backToOrigin"
size="s"
href={backButtonUrl}
onClick={handleBackOnClick}
textProps={{ className: 'text' }}
>
{backButtonLabel || (
<FormattedMessage id="xpack.securitySolution.list.backButton" defaultMessage="Back" />
)}
</EuiButtonEmpty>
);
}
);
BackToExternalAppSecondaryButton.displayName = 'BackToExternalAppSecondaryButton';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { BackToExternalAppSecondaryButton } from './back_to_external_app_secondary_button';

View file

@ -13,7 +13,7 @@ export const StyledEuiFlexGroup = styled(EuiFlexGroup)`
min-height: calc(100vh - 140px);
`;
export const ManagementEmptyStateWraper = memo(({ children }) => {
export const ManagementEmptyStateWrapper = memo(({ children }) => {
return (
<StyledEuiFlexGroup direction="column" alignItems="center">
<EuiPageTemplate template="centeredContent">{children}</EuiPageTemplate>
@ -21,4 +21,4 @@ export const ManagementEmptyStateWraper = memo(({ children }) => {
);
});
ManagementEmptyStateWraper.displayName = 'ManagementEmptyStateWraper';
ManagementEmptyStateWrapper.displayName = 'ManagementEmptyStateWrapper';

View file

@ -7,14 +7,14 @@
import React, { memo } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { ManagementEmptyStateWraper } from './management_empty_state_wraper';
import { ManagementEmptyStateWrapper } from './management_empty_state_wrapper';
export const ManagementPageLoader = memo<{ 'data-test-subj': string }>(
({ 'data-test-subj': dataTestSubj }) => {
return (
<ManagementEmptyStateWraper>
<ManagementEmptyStateWrapper>
<EuiLoadingSpinner data-test-subj={dataTestSubj} size="l" />
</ManagementEmptyStateWraper>
</ManagementEmptyStateWrapper>
);
}
);

View file

@ -9,7 +9,7 @@ import React, { memo } from 'react';
import styled, { css } from 'styled-components';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { ManagementEmptyStateWraper } from '../../../../../components/management_empty_state_wraper';
import { ManagementEmptyStateWrapper } from '../../../../../components/management_empty_state_wrapper';
const EmptyPrompt = styled(EuiEmptyPrompt)`
${() => css`
@ -21,9 +21,10 @@ export const EventFiltersListEmptyState = memo<{
onAdd: () => void;
/** Should the Add button be disabled */
isAddDisabled?: boolean;
}>(({ onAdd, isAddDisabled = false }) => {
backComponent?: React.ReactNode;
}>(({ onAdd, isAddDisabled = false, backComponent }) => {
return (
<ManagementEmptyStateWraper>
<ManagementEmptyStateWrapper>
<EmptyPrompt
data-test-subj="eventFiltersEmpty"
iconType="plusInCircle"
@ -41,7 +42,7 @@ export const EventFiltersListEmptyState = memo<{
defaultMessage="Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch."
/>
}
actions={
actions={[
<EuiButton
fill
isDisabled={isAddDisabled}
@ -52,10 +53,11 @@ export const EventFiltersListEmptyState = memo<{
id="xpack.securitySolution.eventFilters.listEmpty.addButton"
defaultMessage="Add event filter"
/>
</EuiButton>
}
</EuiButton>,
...(backComponent ? [backComponent] : []),
]}
/>
</ManagementEmptyStateWraper>
</ManagementEmptyStateWrapper>
);
});

View file

@ -221,11 +221,27 @@ describe('When on the Event Filters List Page', () => {
expect(button).toHaveAttribute('href', '/fleet');
});
it('back button is not present', () => {
it('back button is still present after push history', () => {
act(() => {
history.push('/administration/event_filters');
});
expect(renderResult.queryByTestId('backToOrigin')).toBeNull();
const button = renderResult.queryByTestId('backToOrigin');
expect(button).not.toBeNull();
expect(button).toHaveAttribute('href', '/fleet');
});
});
describe('and the back button is not present', () => {
beforeEach(async () => {
renderResult = render();
act(() => {
history.push('/administration/event_filters');
});
});
it('back button is not present when missing history params', () => {
const button = renderResult.queryByTestId('backToOrigin');
expect(button).toBeNull();
});
});
});

View file

@ -45,6 +45,7 @@ import {
import { EventFilterDeleteModal } from './components/event_filter_delete_modal';
import { SearchExceptions } from '../../../components/search_exceptions';
import { BackToExternalAppSecondaryButton } from '../../../components/back_to_external_app_secondary_button';
import { BackToExternalAppButton } from '../../../components/back_to_external_app_button';
import { ABOUT_EVENT_FILTERS } from './translations';
import { useGetEndpointSpecificPolicies } from '../../../services/policies/hooks';
@ -52,6 +53,7 @@ import { useToasts } from '../../../../common/lib/kibana';
import { getLoadPoliciesError } from '../../../common/translations';
import { useEndpointPoliciesToArtifactPolicies } from '../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies';
import { ManagementPageLoader } from '../../../components/management_page_loader';
import { useMemoizedRouteState } from '../../../common/hooks';
type ArtifactEntryCardType = typeof ArtifactEntryCard;
@ -103,6 +105,20 @@ export const EventFiltersListPage = memo(() => {
const navigateCallback = useEventFiltersNavigateCallback();
const showFlyout = !!location.show;
const memoizedRouteState = useMemoizedRouteState(routeState);
const backButtonEmptyComponent = useMemo(() => {
if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) {
return <BackToExternalAppSecondaryButton {...memoizedRouteState} />;
}
}, [memoizedRouteState]);
const backButtonHeaderComponent = useMemo(() => {
if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) {
return <BackToExternalAppButton {...memoizedRouteState} />;
}
}, [memoizedRouteState]);
// load the list of policies
const policiesRequest = useGetEndpointSpecificPolicies({
onError: (err) => {
@ -141,13 +157,6 @@ export const EventFiltersListPage = memo(() => {
}
}, [dispatch, formEntry, history, isActionError, location, navigateCallback]);
const backButton = useMemo(() => {
if (routeState && routeState.onBackButtonNavigateTo) {
return <BackToExternalAppButton {...routeState} />;
}
return null;
}, [routeState]);
const handleAddButtonClick = useCallback(
() =>
navigateCallback({
@ -240,7 +249,7 @@ export const EventFiltersListPage = memo(() => {
return (
<AdministrationListPage
headerBackComponent={backButton}
headerBackComponent={backButtonHeaderComponent}
title={
<FormattedMessage
id="xpack.securitySolution.eventFilters.list.pageTitle"
@ -312,7 +321,11 @@ export const EventFiltersListPage = memo(() => {
data-test-subj="eventFiltersContent"
noItemsMessage={
!doesDataExist && (
<EventFiltersListEmptyState onAdd={handleAddButtonClick} isAddDisabled={showFlyout} />
<EventFiltersListEmptyState
onAdd={handleAddButtonClick}
isAddDisabled={showFlyout}
backComponent={backButtonEmptyComponent}
/>
)
}
/>

View file

@ -9,7 +9,7 @@ import React, { memo } from 'react';
import styled, { css } from 'styled-components';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { ManagementEmptyStateWraper } from '../../../../components/management_empty_state_wraper';
import { ManagementEmptyStateWrapper } from '../../../../components/management_empty_state_wrapper';
const EmptyPrompt = styled(EuiEmptyPrompt)`
${() => css`
@ -17,9 +17,12 @@ const EmptyPrompt = styled(EuiEmptyPrompt)`
`}
`;
export const HostIsolationExceptionsEmptyState = memo<{ onAdd: () => void }>(({ onAdd }) => {
export const HostIsolationExceptionsEmptyState = memo<{
onAdd: () => void;
backComponent?: React.ReactNode;
}>(({ onAdd, backComponent }) => {
return (
<ManagementEmptyStateWraper>
<ManagementEmptyStateWrapper>
<EmptyPrompt
data-test-subj="hostIsolationExceptionsEmpty"
iconType="plusInCircle"
@ -37,7 +40,7 @@ export const HostIsolationExceptionsEmptyState = memo<{ onAdd: () => void }>(({
defaultMessage="Add a Host isolation exception to allow isolated hosts to communicate with specific IPs."
/>
}
actions={
actions={[
<EuiButton
fill
onClick={onAdd}
@ -47,10 +50,12 @@ export const HostIsolationExceptionsEmptyState = memo<{ onAdd: () => void }>(({
id="xpack.securitySolution.hostIsolationExceptions.listEmpty.addButton"
defaultMessage="Add Host isolation exception"
/>
</EuiButton>
}
</EuiButton>,
...(backComponent ? [backComponent] : []),
]}
/>
</ManagementEmptyStateWraper>
</ManagementEmptyStateWrapper>
);
});

View file

@ -265,5 +265,47 @@ describe('When on the host isolation exceptions page', () => {
expect(renderResult.queryByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeFalsy();
});
});
describe('and the back button is present', () => {
beforeEach(async () => {
renderResult = render();
act(() => {
history.push(HOST_ISOLATION_EXCEPTIONS_PATH, {
onBackButtonNavigateTo: [{ appId: 'appId' }],
backButtonLabel: 'back to fleet',
backButtonUrl: '/fleet',
});
});
});
it('back button is present', () => {
const button = renderResult.queryByTestId('backToOrigin');
expect(button).not.toBeNull();
expect(button).toHaveAttribute('href', '/fleet');
});
it('back button is still present after push history', () => {
act(() => {
history.push(HOST_ISOLATION_EXCEPTIONS_PATH);
});
const button = renderResult.queryByTestId('backToOrigin');
expect(button).not.toBeNull();
expect(button).toHaveAttribute('href', '/fleet');
});
});
describe('and the back button is not present', () => {
beforeEach(async () => {
renderResult = render();
act(() => {
history.push(HOST_ISOLATION_EXCEPTIONS_PATH);
});
});
it('back button is not present when missing history params', () => {
const button = renderResult.queryByTestId('backToOrigin');
expect(button).toBeNull();
});
});
});
});

View file

@ -24,6 +24,7 @@ import { getLoadPoliciesError } from '../../../common/translations';
import { AdministrationListPage } from '../../../components/administration_list_page';
import { ArtifactEntryCard, ArtifactEntryCardProps } from '../../../components/artifact_entry_card';
import { useEndpointPoliciesToArtifactPolicies } from '../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies';
import { BackToExternalAppSecondaryButton } from '../../../components/back_to_external_app_secondary_button';
import { BackToExternalAppButton } from '../../../components/back_to_external_app_button';
import { ManagementPageLoader } from '../../../components/management_page_loader';
import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content';
@ -42,6 +43,7 @@ import {
useHostIsolationExceptionsNavigateCallback,
useHostIsolationExceptionsSelector,
} from './hooks';
import { useMemoizedRouteState } from '../../../common/hooks';
type HostIsolationExceptionPaginatedContent = PaginatedContentProps<
Immutable<ExceptionListItemSchema>,
@ -56,6 +58,20 @@ export const HostIsolationExceptionsList = () => {
const location = useHostIsolationExceptionsSelector(getCurrentLocation);
const navigateCallback = useHostIsolationExceptionsNavigateCallback();
const memoizedRouteState = useMemoizedRouteState(routeState);
const backButtonEmptyComponent = useMemo(() => {
if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) {
return <BackToExternalAppSecondaryButton {...memoizedRouteState} />;
}
}, [memoizedRouteState]);
const backButtonHeaderComponent = useMemo(() => {
if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) {
return <BackToExternalAppButton {...memoizedRouteState} />;
}
}, [memoizedRouteState]);
const [itemToDelete, setItemToDelete] = useState<ExceptionListItemSchema | null>(null);
const includedPoliciesParam = location.included_policies;
@ -155,13 +171,6 @@ export const HostIsolationExceptionsList = () => {
[navigateCallback]
);
const backButton = useMemo(() => {
if (routeState && routeState.onBackButtonNavigateTo) {
return <BackToExternalAppButton {...routeState} />;
}
return null;
}, [routeState]);
const handleAddButtonClick = useCallback(
() =>
navigateCallback({
@ -193,7 +202,7 @@ export const HostIsolationExceptionsList = () => {
return (
<AdministrationListPage
headerBackComponent={backButton}
headerBackComponent={backButtonHeaderComponent}
title={
<FormattedMessage
id="xpack.securitySolution.hostIsolationExceptions.list.pageTitle"
@ -272,7 +281,12 @@ export const HostIsolationExceptionsList = () => {
contentClassName="host-isolation-exceptions-container"
data-test-subj="hostIsolationExceptionsContent"
noItemsMessage={
!hasDataToShow && <HostIsolationExceptionsEmptyState onAdd={handleAddButtonClick} />
!hasDataToShow && (
<HostIsolationExceptionsEmptyState
onAdd={handleAddButtonClick}
backComponent={backButtonEmptyComponent}
/>
)
}
/>
</AdministrationListPage>

View file

@ -72,7 +72,7 @@ export const FleetEventFiltersCard = memo<PackageCustomExtensionComponentProps>(
return {
backButtonLabel: i18n.translate(
'xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel',
{ defaultMessage: 'Back to Endpoint Integration' }
{ defaultMessage: 'Return to Endpoint Security integrations' }
),
onBackButtonNavigateTo: [
INTEGRATIONS_PLUGIN_ID,

View file

@ -73,7 +73,7 @@ export const FleetHostIsolationExceptionsCard = memo<PackageCustomExtensionCompo
return {
backButtonLabel: i18n.translate(
'xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.backButtonLabel',
{ defaultMessage: 'Back to Endpoint Integration' }
{ defaultMessage: 'Return to Endpoint Security integrations' }
),
onBackButtonNavigateTo: [
INTEGRATIONS_PLUGIN_ID,

View file

@ -33,7 +33,7 @@ export const FleetTrustedAppsCardWrapper = memo<PackageCustomExtensionComponentP
return {
backButtonLabel: i18n.translate(
'xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel',
{ defaultMessage: 'Back to Endpoint Integration' }
{ defaultMessage: 'Return to Endpoint Security integrations' }
),
onBackButtonNavigateTo: [
INTEGRATIONS_PLUGIN_ID,

View file

@ -8,15 +8,16 @@
import React, { memo } from 'react';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { ManagementEmptyStateWraper } from '../../../../components/management_empty_state_wraper';
import { ManagementEmptyStateWrapper } from '../../../../components/management_empty_state_wrapper';
export const EmptyState = memo<{
onAdd: () => void;
/** Should the Add button be disabled */
isAddDisabled?: boolean;
}>(({ onAdd, isAddDisabled = false }) => {
backComponent?: React.ReactNode;
}>(({ onAdd, isAddDisabled = false, backComponent }) => {
return (
<ManagementEmptyStateWraper>
<ManagementEmptyStateWrapper>
<EuiEmptyPrompt
data-test-subj="trustedAppEmptyState"
iconType="plusInCircle"
@ -34,7 +35,7 @@ export const EmptyState = memo<{
defaultMessage="Add a trusted application to improve performance or alleviate conflicts with other applications running on your hosts."
/>
}
actions={
actions={[
<EuiButton
fill
isDisabled={isAddDisabled}
@ -45,10 +46,11 @@ export const EmptyState = memo<{
id="xpack.securitySolution.trustedapps.list.addButton"
defaultMessage="Add trusted application"
/>
</EuiButton>
}
</EuiButton>,
...(backComponent ? [backComponent] : []),
]}
/>
</ManagementEmptyStateWraper>
</ManagementEmptyStateWrapper>
);
});

View file

@ -858,11 +858,31 @@ describe('When on the Trusted Apps Page', () => {
expect(button).toHaveAttribute('href', '/fleet');
});
it('back button is not present', () => {
it('back button is present after push history', () => {
reactTestingLibrary.act(() => {
history.push('/administration/trusted_apps');
});
expect(renderResult.queryByTestId('backToOrigin')).toBeNull();
const button = renderResult.queryByTestId('backToOrigin');
expect(button).not.toBeNull();
expect(button).toHaveAttribute('href', '/fleet');
});
});
describe('and the back button is not present', () => {
let renderResult: ReturnType<AppContextTestRender['render']>;
beforeEach(async () => {
renderResult = render();
await act(async () => {
await waitForAction('trustedAppsListResourceStateChanged');
});
reactTestingLibrary.act(() => {
history.push('/administration/trusted_apps');
});
});
it('back button is not present when missing history params', () => {
const button = renderResult.queryByTestId('backToOrigin');
expect(button).toBeNull();
});
});
});

View file

@ -31,9 +31,11 @@ import { AppAction } from '../../../../common/store/actions';
import { ABOUT_TRUSTED_APPS, SEARCH_TRUSTED_APP_PLACEHOLDER } from './translations';
import { EmptyState } from './components/empty_state';
import { SearchExceptions } from '../../../components/search_exceptions';
import { BackToExternalAppSecondaryButton } from '../../../components/back_to_external_app_secondary_button';
import { BackToExternalAppButton } from '../../../components/back_to_external_app_button';
import { ListPageRouteState } from '../../../../../common/endpoint/types';
import { ManagementPageLoader } from '../../../components/management_page_loader';
import { useMemoizedRouteState } from '../../../common/hooks';
export const TrustedAppsPage = memo(() => {
const dispatch = useDispatch<Dispatch<AppAction>>();
@ -51,6 +53,20 @@ export const TrustedAppsPage = memo(() => {
})
);
const memoizedRouteState = useMemoizedRouteState(routeState);
const backButtonEmptyComponent = useMemo(() => {
if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) {
return <BackToExternalAppSecondaryButton {...memoizedRouteState} />;
}
}, [memoizedRouteState]);
const backButtonHeaderComponent = useMemo(() => {
if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) {
return <BackToExternalAppButton {...memoizedRouteState} />;
}
}, [memoizedRouteState]);
const handleAddButtonClick = useTrustedAppsNavigateCallback(() => ({
show: 'create',
id: undefined,
@ -75,13 +91,6 @@ export const TrustedAppsPage = memo(() => {
[didEntriesExist, doEntriesExist, isCheckingIfEntriesExists]
);
const backButton = useMemo(() => {
if (routeState && routeState.onBackButtonNavigateTo) {
return <BackToExternalAppButton {...routeState} />;
}
return null;
}, [routeState]);
const addButton = (
<EuiButton
fill
@ -142,7 +151,11 @@ export const TrustedAppsPage = memo(() => {
</EuiFlexGroup>
</>
) : (
<EmptyState onAdd={handleAddButtonClick} isAddDisabled={showCreateFlyout} />
<EmptyState
onAdd={handleAddButtonClick}
isAddDisabled={showCreateFlyout}
backComponent={backButtonEmptyComponent}
/>
)}
</>
);
@ -150,13 +163,13 @@ export const TrustedAppsPage = memo(() => {
return (
<AdministrationListPage
data-test-subj="trustedAppsListPage"
headerBackComponent={backButtonHeaderComponent}
title={
<FormattedMessage
id="xpack.securitySolution.trustedapps.list.pageTitle"
defaultMessage="Trusted applications"
/>
}
headerBackComponent={backButton}
subtitle={ABOUT_TRUSTED_APPS}
actions={addButton}
hideHeader={!canDisplayContent()}