[Synthetics UI] Add Actions popover menu (#136992)

This commit is contained in:
Justin Kambic 2022-08-18 20:38:20 -04:00 committed by GitHub
parent 7be8ae67f9
commit 4c5043c779
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 424 additions and 52 deletions

View file

@ -13,6 +13,7 @@ import {
ConfigKey,
EncryptedSyntheticsSavedMonitor,
} from '../../../../../../../common/runtime_types';
import { useMonitorDetailLocator } from '../../hooks/use_monitor_detail_locator';
export const MonitorDetailsLink = ({
basePath,
@ -30,13 +31,11 @@ export const MonitorDetailsLink = ({
const locationId =
lastSelectedLocationId && monitorHasLocation ? lastSelectedLocationId : firstMonitorLocationId;
const locationUrlQueryParam = locationId ? `?locationId=${locationId}` : '';
const monitorDetailLinkUrl = useMonitorDetailLocator({ monitorId: monitor.id, locationId });
return (
<>
<EuiLink href={`${basePath}/app/synthetics/monitor/${monitor.id}${locationUrlQueryParam}`}>
{monitor.name}
</EuiLink>
<EuiLink href={monitorDetailLinkUrl}>{monitor.name}</EuiLink>
</>
);
};

View file

@ -0,0 +1,152 @@
/*
* 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 from 'react';
import { fireEvent } from '@testing-library/react';
import { render } from '../../../../utils/testing/rtl_helpers';
import { ActionsPopover } from './actions_popover';
import * as editMonitorLocatorModule from '../../hooks/use_edit_monitor_locator';
import * as monitorDetailLocatorModule from '../../hooks/use_monitor_detail_locator';
import * as monitorEnableHandlerModule from '../../../../hooks/use_monitor_enable_handler';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import { MonitorOverviewItem } from '../types';
describe('ActionsPopover', () => {
let testMonitor: MonitorOverviewItem;
beforeEach(() => {
testMonitor = {
location: {
id: 'us_central',
isServiceManaged: true,
},
isEnabled: true,
name: 'Monitor 1',
id: 'somelongstring',
};
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders the popover button', () => {
const { queryByText, getByLabelText } = render(
<ActionsPopover isPopoverOpen={false} setIsPopoverOpen={jest.fn()} monitor={testMonitor} />
);
expect(getByLabelText('Open actions menu'));
expect(queryByText('Actions')).not.toBeInTheDocument();
});
it('opens the popover on click', async () => {
const setIsPopoverOpen = jest.fn();
const isPopoverOpen = false;
const { getByLabelText } = render(
<ActionsPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
monitor={testMonitor}
/>
);
const popoverButton = getByLabelText('Open actions menu');
fireEvent.click(popoverButton);
expect(setIsPopoverOpen).toHaveBeenCalled();
// the popover passes back a function that accepts a bool and returns the inverse,
// so we're calling it here just to make sure the behavior is correct
expect(setIsPopoverOpen.mock.calls[0][0](isPopoverOpen)).toBe(true);
});
it('closes the popover on subsequent click', async () => {
const setIsPopoverOpen = jest.fn();
const isPopoverOpen = true;
const { getByLabelText } = render(
<ActionsPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
monitor={testMonitor}
/>
);
const popoverButton = getByLabelText('Open actions menu');
fireEvent.click(popoverButton);
expect(setIsPopoverOpen).toHaveBeenCalled();
// the popover passes back a function that accepts a bool and returns the inverse,
// so we're calling it here just to make sure the behavior is correct
expect(setIsPopoverOpen.mock.calls[0][0](isPopoverOpen)).toBe(false);
});
it('contains link to edit page', async () => {
jest
.spyOn(editMonitorLocatorModule, 'useEditMonitorLocator')
.mockReturnValue('/a/test/edit/url');
const { getByRole } = render(
<ActionsPopover isPopoverOpen={true} setIsPopoverOpen={jest.fn()} monitor={testMonitor} />
);
expect(getByRole('link')?.getAttribute('href')).toBe('/a/test/edit/url');
});
it('contains link to detail page', async () => {
jest
.spyOn(monitorDetailLocatorModule, 'useMonitorDetailLocator')
.mockReturnValue('/a/test/detail/url');
const { getByRole } = render(
<ActionsPopover isPopoverOpen={true} setIsPopoverOpen={jest.fn()} monitor={testMonitor} />
);
expect(getByRole('link')?.getAttribute('href')).toBe('/a/test/detail/url');
});
it('sets the enabled state', async () => {
const updateMonitorEnabledState = jest.fn();
jest.spyOn(monitorEnableHandlerModule, 'useMonitorEnableHandler').mockReturnValue({
status: FETCH_STATUS.SUCCESS,
isEnabled: true,
updateMonitorEnabledState,
});
const { getByText } = render(
<ActionsPopover isPopoverOpen={true} setIsPopoverOpen={jest.fn()} monitor={testMonitor} />
);
const enableButton = getByText('Disable monitor');
fireEvent.click(enableButton);
expect(updateMonitorEnabledState).toHaveBeenCalledTimes(1);
expect(updateMonitorEnabledState.mock.calls[0]).toEqual([
{
id: 'somelongstring',
isEnabled: true,
location: { id: 'us_central', isServiceManaged: true },
name: 'Monitor 1',
},
false,
]);
});
it('sets enabled state to true', async () => {
const updateMonitorEnabledState = jest.fn();
jest.spyOn(monitorEnableHandlerModule, 'useMonitorEnableHandler').mockReturnValue({
status: FETCH_STATUS.PENDING,
isEnabled: null,
updateMonitorEnabledState,
});
const { getByText } = render(
<ActionsPopover
isPopoverOpen={true}
setIsPopoverOpen={jest.fn()}
monitor={{ ...testMonitor, isEnabled: false }}
/>
);
const enableButton = getByText('Enable monitor');
fireEvent.click(enableButton);
expect(updateMonitorEnabledState).toHaveBeenCalledTimes(1);
expect(updateMonitorEnabledState.mock.calls[0]).toEqual([
{
id: 'somelongstring',
isEnabled: false,
location: { id: 'us_central', isServiceManaged: true },
name: 'Monitor 1',
},
true,
]);
});
});

View file

@ -0,0 +1,220 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { EuiPopover, EuiButtonIcon, EuiContextMenu, useEuiShadow } from '@elastic/eui';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import { useTheme } from '@kbn/observability-plugin/public';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { MonitorOverviewItem } from '../../../../../../../common/runtime_types';
import { useMonitorEnableHandler } from '../../../../hooks/use_monitor_enable_handler';
import { quietFetchOverviewAction } from '../../../../state/overview/actions';
import { selectOverviewState } from '../../../../state/overview/selectors';
import { useEditMonitorLocator } from '../../hooks/use_edit_monitor_locator';
import { useMonitorDetailLocator } from '../../hooks/use_monitor_detail_locator';
interface ActionContainerProps {
boxShadow: string;
}
const ActionContainer = styled.div<ActionContainerProps>`
// position
display: inline-block;
position: relative;
bottom: 42px;
left: 12px;
z-index: 1;
// style
border-radius: ${({ theme }) => theme.eui.euiBorderRadius};
${({ boxShadow }) => boxShadow}
`;
export function ActionsPopover({
isPopoverOpen,
setIsPopoverOpen,
monitor,
}: {
isPopoverOpen: boolean;
monitor: MonitorOverviewItem;
setIsPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const theme = useTheme();
const euiShadow = useEuiShadow('l');
const dispatch = useDispatch();
const { pageState } = useSelector(selectOverviewState);
const detailUrl = useMonitorDetailLocator({
monitorId: monitor.id,
locationId: monitor.location.id,
});
const editUrl = useEditMonitorLocator({ monitorId: monitor.id });
const labels = useMemo(
() => ({
enabledSuccessLabel: enabledSuccessLabel(monitor.name),
disabledSuccessLabel: disabledSuccessLabel(monitor.name),
failureLabel: enabledFailLabel(monitor.name),
}),
[monitor.name]
);
const { status, isEnabled, updateMonitorEnabledState } = useMonitorEnableHandler({
id: monitor.id,
reloadPage: useCallback(() => {
dispatch(quietFetchOverviewAction.get(pageState));
setIsPopoverOpen(false);
}, [dispatch, pageState, setIsPopoverOpen]),
labels,
});
const [enableLabel, setEnableLabel] = useState(
monitor.isEnabled ? disableMonitorLabel : enableMonitorLabel
);
useEffect(() => {
if (status === FETCH_STATUS.LOADING) {
setEnableLabel(enableLabelLoading);
} else if (status === FETCH_STATUS.SUCCESS) {
setEnableLabel(isEnabled ? disableMonitorLabel : enableMonitorLabel);
}
}, [setEnableLabel, status, isEnabled, monitor.isEnabled]);
return (
<ActionContainer boxShadow={euiShadow}>
<EuiPopover
button={
<EuiButtonIcon
aria-label={openActionsMenuAria}
iconType="boxesHorizontal"
color="primary"
size="s"
display="base"
style={{ backgroundColor: theme.eui.euiColorLightestShade }}
onClick={() => setIsPopoverOpen((b: boolean) => !b)}
/>
}
color="lightestShade"
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
anchorPosition="rightUp"
panelPaddingSize="none"
>
<EuiContextMenu
initialPanelId={0}
panels={[
{
id: '0',
title: actionsMenuTitle,
items: [
{
name: actionsMenuGoToMonitorName,
icon: 'sortRight',
href: detailUrl,
},
// not rendering this for now because it requires the detail flyout
// which is not merged yet. Also, this needs to be rendered conditionally,
// the actions menu can be opened within the flyout so there is no point in showing this
// if the user is already in the flyout.
// {
// name: 'Quick inspect',
// icon: 'inspect',
// },
// not rendering this for now because the manual test flyout is
// still in the design phase
// {
// name: 'Run test manually',
// icon: 'beaker',
// },
{
name: actionsMenuEditMonitorName,
icon: 'pencil',
href: editUrl,
},
{
name: enableLabel,
icon: 'invert',
onClick: () => {
if (status !== FETCH_STATUS.LOADING)
updateMonitorEnabledState(monitor, !monitor.isEnabled);
},
},
],
},
]}
/>
</EuiPopover>
</ActionContainer>
);
}
const openActionsMenuAria = i18n.translate(
'xpack.synthetics.overview.actions.openPopover.ariaLabel',
{
defaultMessage: 'Open actions menu',
}
);
const actionsMenuTitle = i18n.translate('xpack.synthetics.overview.actions.menu.title', {
defaultMessage: 'Actions',
description: 'This is the text in the heading of a menu containing a set of actions',
});
const actionsMenuGoToMonitorName = i18n.translate(
'xpack.synthetics.overview.actions.goToMonitor.name',
{
defaultMessage: 'Go to monitor',
description:
'This is the text for a menu item that will take the user to the monitor detail page',
}
);
const actionsMenuEditMonitorName = i18n.translate(
'xpack.synthetics.overview.actions.editMonitor.name',
{
defaultMessage: 'Edit monitor',
description:
'This is the text for a menu item that will take the user to the monitor edit page',
}
);
const enableLabelLoading = i18n.translate('xpack.synthetics.overview.actions.enableLabel', {
defaultMessage: 'Loading...',
});
const enableMonitorLabel = i18n.translate(
'xpack.synthetics.overview.actions.enableLabelEnableMonitor',
{
defaultMessage: 'Enable monitor',
}
);
const disableMonitorLabel = i18n.translate(
'xpack.synthetics.overview.actions.enableLabelDisableMonitor',
{
defaultMessage: 'Disable monitor',
}
);
const enabledSuccessLabel = (name: string) =>
i18n.translate('xpack.synthetics.overview.actions.enabledSuccessLabel', {
defaultMessage: 'Monitor "{name}" enabled successfully',
values: { name },
});
export const disabledSuccessLabel = (name: string) =>
i18n.translate('xpack.synthetics.overview.actions.disabledSuccessLabel', {
defaultMessage: 'Monitor "{name}" disabled successfully.',
values: { name },
});
export const enabledFailLabel = (name: string) =>
i18n.translate('xpack.synthetics.overview.actions.enabledFailLabel', {
defaultMessage: 'Unable to update monitor "{name}".',
values: { name },
});

View file

@ -4,15 +4,16 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { Chart, Settings, Metric, MetricTrendShape } from '@elastic/charts';
import { EuiPanel, EuiLoadingChart } from '@elastic/eui';
import { DARK_THEME } from '@elastic/charts';
import { useTheme } from '@kbn/observability-plugin/public';
import { useLocationName, useStatusByLocation } from '../../../../hooks';
import { formatDuration } from '../../../../utils/formatting';
import { Ping } from '../../../../../../../common/runtime_types';
import { MonitorOverviewItem, Ping } from '../../../../../../../common/runtime_types';
import { ActionsPopover } from './actions_popover';
export const getColor = (theme: ReturnType<typeof useTheme>, isEnabled: boolean, ping?: Ping) => {
if (!isEnabled) {
@ -24,24 +25,20 @@ export const getColor = (theme: ReturnType<typeof useTheme>, isEnabled: boolean,
};
export const MetricItem = ({
monitorId,
locationId,
monitorName,
isMonitorEnabled,
monitor,
averageDuration,
data,
loaded,
}: {
monitorId: string;
locationId: string;
monitorName: string;
isMonitorEnabled: boolean;
monitor: MonitorOverviewItem;
data: Array<{ x: number; y: number }>;
averageDuration: number;
loaded: boolean;
}) => {
const locationName = useLocationName({ locationId });
const { locations } = useStatusByLocation(monitorId);
const [isMouseOver, setIsMouseOver] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const locationName = useLocationName({ locationId: monitor.location?.id });
const { locations } = useStatusByLocation(monitor.id);
const ping = locations.find((loc) => loc.observer?.geo?.name === locationName);
const theme = useTheme();
@ -53,6 +50,16 @@ export const MetricItem = ({
>
{loaded ? (
<EuiPanel
onMouseOver={() => {
if (!isMouseOver) {
setIsMouseOver(true);
}
}}
onMouseLeave={() => {
if (isMouseOver) {
setIsMouseOver(false);
}
}}
style={{
padding: '0px',
height: '100%',
@ -62,11 +69,11 @@ export const MetricItem = ({
<Chart>
<Settings baseTheme={DARK_THEME} />
<Metric
id={`${monitorId}-${locationId}`}
id={`${monitor.id}-${monitor.location?.id}`}
data={[
[
{
title: monitorName,
title: monitor.name,
subtitle: locationName,
value: averageDuration,
trendShape: MetricTrendShape.Area,
@ -79,12 +86,19 @@ export const MetricItem = ({
</span>
),
valueFormatter: (d: number) => formatDuration(d),
color: getColor(theme, isMonitorEnabled, ping),
color: getColor(theme, monitor.isEnabled, ping),
},
],
]}
/>
</Chart>
{(isMouseOver || isPopoverOpen) && (
<ActionsPopover
monitor={monitor}
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
/>
)}
</EuiPanel>
) : (
<EuiLoadingChart mono />

View file

@ -40,12 +40,7 @@ export const OverviewGrid = () => {
key={`${monitor.id}-${monitor.location?.id}`}
data-test-subj="syntheticsOverviewGridItem"
>
<OverviewGridItem
monitorId={monitor.id}
locationId={monitor.location?.id}
monitorName={monitor.name}
isMonitorEnabled={monitor.isEnabled}
/>
<OverviewGridItem monitor={monitor} />
</EuiFlexItem>
))}
</EuiFlexGrid>

View file

@ -7,28 +7,14 @@
import React from 'react';
import { MetricItem } from './metric_item';
import { useLast50DurationChart } from '../../../../hooks';
import { MonitorOverviewItem } from '../../../../../../../common/runtime_types';
export const OverviewGridItem = ({
monitorId,
monitorName,
locationId,
isMonitorEnabled,
}: {
monitorId: string;
monitorName: string;
locationId: string;
isMonitorEnabled: boolean;
}) => {
const { data, loading, averageDuration } = useLast50DurationChart({ locationId, monitorId });
export const OverviewGridItem = ({ monitor }: { monitor: MonitorOverviewItem }) => {
const { data, loading, averageDuration } = useLast50DurationChart({
locationId: monitor.location?.id,
monitorId: monitor.id,
});
return (
<MetricItem
monitorId={monitorId}
monitorName={monitorName}
isMonitorEnabled={isMonitorEnabled}
locationId={locationId}
data={data}
loaded={!loading}
averageDuration={averageDuration}
/>
<MetricItem monitor={monitor} data={data} loaded={!loading} averageDuration={averageDuration} />
);
};

View file

@ -37,7 +37,7 @@ export function useLast50DurationChart({
const coords = hits
.reverse() // results are returned in desc order by timestamp. Reverse to ensure the data is in asc order by timestamp
.map((hit, index) => {
const duration = hit['monitor.duration.us']?.[0];
const duration = hit?.['monitor.duration.us']?.[0];
totalDuration += duration || 0;
if (duration === undefined) {
return null;

View file

@ -9,7 +9,11 @@ import { useKibana } from '@kbn/kibana-react-plugin/public';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { ConfigKey, EncryptedSyntheticsMonitor } from '../components/monitors_page/overview/types';
import {
ConfigKey,
EncryptedSyntheticsMonitor,
MonitorOverviewItem,
} from '../components/monitors_page/overview/types';
import {
clearMonitorUpsertStatus,
fetchUpsertMonitorAction,
@ -37,7 +41,7 @@ export function useMonitorEnableHandler({
const savedObjEnabledState = upsertStatuses[id]?.enabled;
const [isEnabled, setIsEnabled] = useState<boolean | null>(null);
const updateMonitorEnabledState = useCallback(
(monitor: EncryptedSyntheticsMonitor, enabled: boolean) => {
(monitor: EncryptedSyntheticsMonitor | MonitorOverviewItem, enabled: boolean) => {
dispatch(
fetchUpsertMonitorAction({
id,
@ -82,5 +86,5 @@ export function useMonitorEnableHandler({
savedObjEnabledState,
]);
return { isEnabled, setIsEnabled, updateMonitorEnabledState, status };
return { isEnabled, updateMonitorEnabledState, status };
}

View file

@ -10,6 +10,7 @@ import { createAction } from '@reduxjs/toolkit';
import {
EncryptedSyntheticsMonitor,
MonitorManagementListResult,
MonitorOverviewItem,
} from '../../../../../common/runtime_types';
import { createAsyncAction } from '../utils/actions';
@ -22,7 +23,7 @@ export const fetchMonitorListAction = createAsyncAction<
export interface UpsertMonitorRequest {
id: string;
monitor: EncryptedSyntheticsMonitor;
monitor: EncryptedSyntheticsMonitor | MonitorOverviewItem;
}
export const fetchUpsertMonitorAction = createAction<UpsertMonitorRequest>('fetchUpsertMonitor');
export const fetchUpsertSuccessAction = createAction<{

View file

@ -11,6 +11,7 @@ import {
FetchMonitorManagementListQueryArgs,
MonitorManagementListResult,
MonitorManagementListResultCodec,
MonitorOverviewItem,
ServiceLocationErrors,
SyntheticsMonitor,
} from '../../../../../common/runtime_types';
@ -54,7 +55,7 @@ export const fetchUpsertMonitor = async ({
monitor,
id,
}: {
monitor: SyntheticsMonitor | EncryptedSyntheticsMonitor;
monitor: SyntheticsMonitor | EncryptedSyntheticsMonitor | MonitorOverviewItem;
id?: string;
}): Promise<{ attributes: { errors: ServiceLocationErrors } } | SyntheticsMonitor> => {
if (id) {