[Security Solution][Endpoint] Fix UX bugs (#121410)

* move back button over flyout header

fixes elastic/security-team/issues/2394

* remove border on details and activity log tabs

fixes elastic/security-team/issues/2395

* update isolation action icons

fixes elastic/security-team/issues/2397

* update link copy

fixes security-team/issues/2398

* fix gutter size

fixes elastic/security-team/issues/2396

* fix refresh button fill color

fixes elastic/security-team/issues/2396

* rename

review changes

* redundant classnames, implicit prop value

review change

* consistent prop name all the way through

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ashokaditya 2021-12-17 16:53:01 +01:00 committed by GitHub
parent 4c1d1aef73
commit b1b3726458
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 76 additions and 153 deletions

View file

@ -32,33 +32,34 @@ const QueryStringInput = withKibana(QueryStringInputUI);
// @internal
export interface QueryBarTopRowProps {
query?: Query;
onSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void;
onChange: (payload: { dateRange: TimeRange; query?: Query }) => void;
onRefresh?: (payload: { dateRange: TimeRange }) => void;
customSubmitButton?: any;
dataTestSubj?: string;
disableAutoFocus?: boolean;
screenTitle?: string;
indexPatterns?: Array<IIndexPattern | string>;
isLoading?: boolean;
prepend?: React.ComponentProps<typeof EuiFieldText>['prepend'];
showQueryInput?: boolean;
showDatePicker?: boolean;
dateRangeFrom?: string;
dateRangeTo?: string;
isRefreshPaused?: boolean;
refreshInterval?: number;
showAutoRefreshOnly?: boolean;
onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void;
customSubmitButton?: any;
isDirty: boolean;
timeHistory?: TimeHistoryContract;
indicateNoData?: boolean;
disableAutoFocus?: boolean;
fillSubmitButton: boolean;
iconType?: EuiIconProps['type'];
placeholder?: string;
indexPatterns?: Array<IIndexPattern | string>;
indicateNoData?: boolean;
isClearable?: boolean;
isDirty: boolean;
isLoading?: boolean;
isRefreshPaused?: boolean;
nonKqlMode?: 'lucene' | 'text';
nonKqlModeHelpText?: string;
onChange: (payload: { dateRange: TimeRange; query?: Query }) => void;
onRefresh?: (payload: { dateRange: TimeRange }) => void;
onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void;
onSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void;
placeholder?: string;
prepend?: React.ComponentProps<typeof EuiFieldText>['prepend'];
query?: Query;
refreshInterval?: number;
screenTitle?: string;
showQueryInput?: boolean;
showDatePicker?: boolean;
showAutoRefreshOnly?: boolean;
timeHistory?: TimeHistoryContract;
timeRangeForSuggestionsOverride?: boolean;
}
@ -229,7 +230,7 @@ export default function QueryBarTopRow(props: QueryBarTopRowProps) {
isDisabled={isDateRangeInvalid}
isLoading={props.isLoading}
onClick={onClickSubmitButton}
fill={false}
fill={props.fillSubmitButton}
data-test-subj="querySubmitButton"
/>
);

View file

@ -77,6 +77,8 @@ export interface SearchBarOwnProps {
nonKqlModeHelpText?: string;
// defines padding; use 'inPage' to avoid extra padding; use 'detached' if the searchBar appears at the very top of the view, without any wrapper
displayStyle?: 'inPage' | 'detached';
// super update button background fill control
fillSubmitButton?: boolean;
}
export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps;
@ -365,6 +367,7 @@ class SearchBarUI extends Component<SearchBarProps, State> {
onSubmit={this.onQueryBarSubmit}
indexPatterns={this.props.indexPatterns}
isLoading={this.props.isLoading}
fillSubmitButton={this.props.fillSubmitButton || false}
prepend={this.props.showFilterBar ? savedQueryManagement : undefined}
showDatePicker={this.props.showDatePicker}
dateRangeFrom={this.state.dateRangeFrom}

View file

@ -61,6 +61,7 @@ export const AdminSearchBar = memo(() => {
indexPatterns={clonedIndexPatterns}
timeHistory={timeHistory}
onQuerySubmit={onQuerySubmit}
fillSubmitButton={true}
isLoading={false}
iconType="search"
showFilterBar={false}

View file

@ -5,15 +5,23 @@
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import React, { memo, useMemo, MouseEventHandler } from 'react';
import { i18n } from '@kbn/i18n';
import { FlyoutSubHeader, FlyoutSubHeaderProps } from './flyout_sub_header';
import { EuiButtonEmpty, CommonProps } from '@elastic/eui';
import { getEndpointDetailsPath } from '../../../../../common/routing';
import { useNavigateByRouterEventHandler } from '../../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler';
import { useEndpointSelector } from '../../hooks';
import { uiQueryParams } from '../../../store/selectors';
import { useAppUrl } from '../../../../../../common/lib/kibana/hooks';
type BackButtonProps = CommonProps & {
backButton?: {
title: string;
onClick: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
href?: string;
};
};
export const BackToEndpointDetailsFlyoutSubHeader = memo<{ endpointId: string }>(
({ endpointId }) => {
const { getAppUrl } = useAppUrl();
@ -28,13 +36,11 @@ export const BackToEndpointDetailsFlyoutSubHeader = memo<{ endpointId: string }>
}),
[currentUrlQueryParams, endpointId]
);
const backToDetailsClickHandler = useNavigateByRouterEventHandler(detailsRoutePath);
const backButtonProp = useMemo((): FlyoutSubHeaderProps['backButton'] => {
const backButtonProps = useMemo((): BackButtonProps['backButton'] => {
return {
title: i18n.translate('xpack.securitySolution.endpoint.policyResponse.backLinkTitle', {
defaultMessage: 'Endpoint Details',
defaultMessage: 'Endpoint details',
}),
href: getAppUrl({ path: detailsRoutePath }),
onClick: backToDetailsClickHandler,
@ -42,10 +48,19 @@ export const BackToEndpointDetailsFlyoutSubHeader = memo<{ endpointId: string }>
}, [backToDetailsClickHandler, getAppUrl, detailsRoutePath]);
return (
<FlyoutSubHeader
backButton={backButtonProp}
data-test-subj="endpointDetailsPolicyResponseFlyoutHeader"
/>
<div>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiButtonEmpty
flush="both"
data-test-subj="flyoutSubHeaderBackButton"
iconType="arrowLeft"
size="xs"
href={backButtonProps?.href ?? ''}
onClick={backButtonProps?.onClick}
>
{backButtonProps?.title}
</EuiButtonEmpty>
</div>
);
}
);

View file

@ -68,7 +68,9 @@ export const EndpointDetailsFlyoutTabs = memo(
return (
<>
<EndpointDetailsFlyoutHeader hostname={hostname} hasBorder>
<EuiTabs style={{ marginBottom: '-25px' }}>{renderTabs}</EuiTabs>
<EuiTabs bottomBorder={false} style={{ marginBottom: '-25px' }}>
{renderTabs}
</EuiTabs>
</EndpointDetailsFlyoutHeader>
<EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
{selectedTab?.content}

View file

@ -9,10 +9,9 @@ import React, { memo, useCallback, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { Dispatch } from 'redux';
import { EuiForm } from '@elastic/eui';
import { EuiForm, EuiFlyoutBody } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { HostMetadata } from '../../../../../../../common/endpoint/types';
import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader';
import {
EndpointIsolatedFormProps,
EndpointIsolateForm,
@ -20,7 +19,6 @@ import {
EndpointUnisolateForm,
ActionCompletionReturnButton,
} from '../../../../../../common/components/endpoint/host_isolation';
import { FlyoutBodyNoTopPadding } from './flyout_body_no_top_padding';
import { getEndpointDetailsPath } from '../../../../../common/routing';
import { useEndpointSelector } from '../../hooks';
import {
@ -87,15 +85,13 @@ export const EndpointIsolationFlyoutPanel = memo<{
return (
<>
<BackToEndpointDetailsFlyoutSubHeader endpointId={hostMeta.agent.id} />
{wasSuccessful && (
<EndpointIsolateSuccess
hostName={hostMeta.host.name}
isolateAction={isCurrentlyIsolated ? 'unisolateHost' : 'isolateHost'}
/>
)}
<FlyoutBodyNoTopPadding>
<EuiFlyoutBody>
{wasSuccessful ? (
<ActionCompletionReturnButton
onClick={handleCancel}
@ -120,7 +116,7 @@ export const EndpointIsolationFlyoutPanel = memo<{
/>
</EuiForm>
)}
</FlyoutBodyNoTopPadding>
</EuiFlyoutBody>
</>
);
});

View file

@ -1,19 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import styled from 'styled-components';
import { EuiFlyoutBody } from '@elastic/eui';
/**
* Removes the `padding-top` from the `EuiFlyoutBody` component. Normally done when there is a
* sub-header present above the flyout body.
*/
export const FlyoutBodyNoTopPadding = styled(EuiFlyoutBody)`
.euiFlyoutBody__overflowContent {
padding-top: 0;
}
`;

View file

@ -9,13 +9,16 @@ import React, { memo } from 'react';
import { EuiFlyoutHeader, EuiLoadingContent, EuiToolTip, EuiTitle } from '@elastic/eui';
import { useEndpointSelector } from '../../hooks';
import { detailsLoading } from '../../../store/selectors';
import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader';
export const EndpointDetailsFlyoutHeader = memo(
({
endpointId,
hasBorder = false,
hostname,
children,
}: {
endpointId?: string;
hasBorder?: boolean;
hostname?: string;
children?: React.ReactNode | React.ReactNodeArray;
@ -24,6 +27,8 @@ export const EndpointDetailsFlyoutHeader = memo(
return (
<EuiFlyoutHeader hasBorder={hasBorder}>
{endpointId && <BackToEndpointDetailsFlyoutSubHeader endpointId={endpointId} />}
{hostDetailsLoading ? (
<EuiLoadingContent lines={1} />
) : (

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, MouseEventHandler } from 'react';
import { EuiFlyoutHeader, CommonProps, EuiButtonEmpty } from '@elastic/eui';
import styled from 'styled-components';
export type FlyoutSubHeaderProps = CommonProps & {
children?: React.ReactNode;
backButton?: {
title: string;
onClick: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
href?: string;
};
};
const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)`
padding: ${(props) => props.theme.eui.paddingSizes.s};
&.hasButtons {
.buttons {
padding-bottom: ${(props) => props.theme.eui.paddingSizes.s};
}
.flyoutSubHeaderBackButton {
font-size: ${(props) => props.theme.eui.euiFontSizeXS};
}
.back-button-content {
padding-left: 0;
&-text {
margin-left: 0;
}
}
}
.flyout-content {
padding-left: ${(props) => props.theme.eui.paddingSizes.m};
}
`;
const BUTTON_CONTENT_PROPS = Object.freeze({ className: 'back-button-content' });
const BUTTON_TEXT_PROPS = Object.freeze({ className: 'back-button-content-text' });
/**
* A Eui Flyout Header component that has its styles adjusted to display a panel sub-header.
* Component also provides a way to display a "back" button above the header title.
*/
export const FlyoutSubHeader = memo<FlyoutSubHeaderProps>(
({ children, backButton, ...otherProps }) => {
return (
<StyledEuiFlyoutHeader {...otherProps} className={backButton && `hasButtons`}>
{backButton && (
<div className="buttons">
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiButtonEmpty
data-test-subj="flyoutSubHeaderBackButton"
iconType="arrowLeft"
contentProps={BUTTON_CONTENT_PROPS}
textProps={BUTTON_TEXT_PROPS}
size="xs"
href={backButton?.href ?? ''}
onClick={backButton?.onClick}
className="flyoutSubHeaderBackButton"
>
{backButton?.title}
</EuiButtonEmpty>
</div>
)}
<div className={'flyout-content'}>{children}</div>
</StyledEuiFlyoutHeader>
);
}
);
FlyoutSubHeader.displayName = 'FlyoutSubHeader';

View file

@ -38,13 +38,11 @@ import {
import { useEndpointSelector } from '../hooks';
import * as i18 from '../translations';
import { ActionsMenu } from './components/actions_menu';
import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader';
import {
EndpointDetailsFlyoutTabs,
EndpointDetailsTabsTypes,
} from './components/endpoint_details_tabs';
import { EndpointIsolationFlyoutPanel } from './components/endpoint_isolate_flyout_panel';
import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding';
import { EndpointDetailsFlyoutHeader } from './components/flyout_header';
import { EndpointActivityLog } from './endpoint_activity_log';
import { EndpointDetailsContent } from './endpoint_details_content';
@ -126,7 +124,11 @@ export const EndpointDetails = memo(() => {
return (
<>
{(show === 'policy_response' || show === 'isolate' || show === 'unisolate') && (
<EndpointDetailsFlyoutHeader hostname={hostDetails?.host?.hostname} />
<EndpointDetailsFlyoutHeader
hasBorder
endpointId={hostDetails?.agent.id}
hostname={hostDetails?.host?.hostname}
/>
)}
{hostDetails === undefined ? (
<EuiFlyoutBody>
@ -174,9 +176,7 @@ const PolicyResponseFlyoutPanel = memo<{
return (
<>
<BackToEndpointDetailsFlyoutSubHeader endpointId={hostMeta.agent.id} />
<FlyoutBodyNoTopPadding
<EuiFlyoutBody
data-test-subj="endpointDetailsPolicyResponseFlyoutBody"
className="endpointDetailsPolicyResponseFlyoutBody"
>
@ -218,7 +218,7 @@ const PolicyResponseFlyoutPanel = memo<{
responseAttentionCount={responseAttentionCount}
/>
)}
</FlyoutBodyNoTopPadding>
</EuiFlyoutBody>
</>
);
});

View file

@ -65,7 +65,7 @@ export const useEndpointActionItems = (
// Un-isolate is always available to users regardless of license level
isolationActions.push({
'data-test-subj': 'unIsolateLink',
icon: 'logoSecurity',
icon: 'lockOpen',
key: 'unIsolateHost',
navigateAppId: APP_UI_ID,
navigateOptions: {
@ -83,7 +83,7 @@ export const useEndpointActionItems = (
// For Platinum++ licenses, users also have ability to isolate
isolationActions.push({
'data-test-subj': 'isolateLink',
icon: 'logoSecurity',
icon: 'lock',
key: 'isolateHost',
navigateAppId: APP_UI_ID,
navigateOptions: {

View file

@ -1125,9 +1125,7 @@ describe('when on the endpoint list page', () => {
});
it('should display policy response sub-panel', async () => {
expect(
await renderResult.findByTestId('endpointDetailsPolicyResponseFlyoutHeader')
).not.toBeNull();
expect(await renderResult.findByTestId('flyoutSubHeaderBackButton')).not.toBeNull();
expect(
await renderResult.findByTestId('endpointDetailsPolicyResponseFlyoutBody')
).not.toBeNull();
@ -1214,7 +1212,7 @@ describe('when on the endpoint list page', () => {
it('should include the back to details link', async () => {
const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton');
expect(subHeaderBackLink.textContent).toBe('Endpoint Details');
expect(subHeaderBackLink.textContent).toBe('Endpoint details');
expect(subHeaderBackLink.getAttribute('href')).toEqual(
`${APP_PATH}${MANAGEMENT_PATH}/endpoints?page_index=0&page_size=10&selected_endpoint=1&show=details`
);

View file

@ -658,7 +658,7 @@ export const EndpointList = () => {
</>
)}
{transformFailedCallout}
<EuiFlexGroup>
<EuiFlexGroup gutterSize="s">
{shouldShowKQLBar && (
<EuiFlexItem>
<AdminSearchBar />

View file

@ -121,7 +121,7 @@ describe('Policy Details', () => {
const backToListLink = policyView.find('BackToExternalAppButton');
expect(backToListLink.prop('backButtonUrl')).toBe(`/app/security${endpointListPath}`);
expect(backToListLink.text()).toBe('Back to endpoint hosts');
expect(backToListLink.text()).toBe('View all endpoints');
const pageTitle = policyView.find('span[data-test-subj="header-page-title"]');
expect(pageTitle).toHaveLength(1);

View file

@ -57,7 +57,7 @@ export const PolicyDetails = React.memo(() => {
backButtonLabel: i18n.translate(
'xpack.securitySolution.endpoint.policy.details.backToListTitle',
{
defaultMessage: 'Back to endpoint hosts',
defaultMessage: 'View all endpoints',
}
),
backButtonUrl: getAppUrl({ path: endpointListPath }),