mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Embeddable enhancement (#125299)
* attach charts to cases * takes lens attributes * update view mode * fix unit test * fix types * add singleMetric component * fix types * fix types * styling * review * styling * unit tests * rename owner to caseOwner * fix unit test * unit test Co-authored-by: shahzad31 <shahzad.muhammad@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
448436f89f
commit
b0cdbd9452
12 changed files with 618 additions and 77 deletions
|
@ -12,6 +12,8 @@ export function plugin(initializerContext: PluginInitializerContext) {
|
|||
return new CasesUiPlugin(initializerContext);
|
||||
}
|
||||
|
||||
export { DRAFT_COMMENT_STORAGE_ID } from './components/markdown_editor/plugins/lens/constants';
|
||||
|
||||
export type { CasesUiPlugin };
|
||||
export type { CasesUiStart } from './types';
|
||||
export type { GetCasesProps } from './methods/get_cases';
|
||||
|
|
|
@ -36,6 +36,7 @@
|
|||
"requiredBundles": [
|
||||
"data",
|
||||
"dataViews",
|
||||
"embeddable",
|
||||
"kibanaReact",
|
||||
"kibanaUtils",
|
||||
"lens"
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
* 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 Embeddable from './embeddable';
|
||||
import { LensPublicStart } from '../../../../../../lens/public';
|
||||
import { IndexPatternState } from '../hooks/use_app_index_pattern';
|
||||
import { render } from '../rtl_helpers';
|
||||
import { AddToCaseAction } from '../header/add_to_case_action';
|
||||
import { ActionTypes } from './use_actions';
|
||||
|
||||
jest.mock('../header/add_to_case_action', () => ({
|
||||
AddToCaseAction: jest.fn(() => <div>mockAddToCaseAction</div>),
|
||||
}));
|
||||
|
||||
const mockLensAttrs = {
|
||||
title: '[Host] KPI Hosts - metric 1',
|
||||
description: '',
|
||||
visualizationType: 'lnsMetric',
|
||||
state: {
|
||||
visualization: {
|
||||
accessor: 'b00c65ea-32be-4163-bfc8-f795b1ef9d06',
|
||||
layerId: '416b6fad-1923-4f6a-a2df-b223bb287e30',
|
||||
layerType: 'data',
|
||||
},
|
||||
query: {
|
||||
language: 'kuery',
|
||||
query: '',
|
||||
},
|
||||
filters: [],
|
||||
datasourceStates: {
|
||||
indexpattern: {
|
||||
layers: {
|
||||
'416b6fad-1923-4f6a-a2df-b223bb287e30': {
|
||||
columnOrder: ['b00c65ea-32be-4163-bfc8-f795b1ef9d06'],
|
||||
columns: {
|
||||
'b00c65ea-32be-4163-bfc8-f795b1ef9d06': {
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: ' ',
|
||||
operationType: 'unique_count',
|
||||
scale: 'ratio',
|
||||
sourceField: 'host.name',
|
||||
},
|
||||
},
|
||||
incompleteColumns: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
references: [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: 'security-solution-default',
|
||||
name: 'indexpattern-datasource-current-indexpattern',
|
||||
},
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: 'security-solution-default',
|
||||
name: 'indexpattern-datasource-layer-416b6fad-1923-4f6a-a2df-b223bb287e30',
|
||||
},
|
||||
{
|
||||
type: 'tag',
|
||||
id: 'security-solution-default',
|
||||
name: 'tag-ref-security-solution-default',
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockTimeRange = {
|
||||
from: '2022-02-15T16:00:00.000Z',
|
||||
to: '2022-02-16T15:59:59.999Z',
|
||||
};
|
||||
const mockOwner = 'securitySolution';
|
||||
const mockAppId = 'securitySolutionUI';
|
||||
const mockIndexPatterns = {} as IndexPatternState;
|
||||
const mockReportType = 'kpi-over-time';
|
||||
const mockTitle = 'mockTitle';
|
||||
const mockLens = {
|
||||
EmbeddableComponent: jest.fn((props) => {
|
||||
return (
|
||||
<div
|
||||
data-test-subj={
|
||||
props.id === 'exploratoryView-singleMetric'
|
||||
? 'exploratoryView-singleMetric'
|
||||
: 'exploratoryView'
|
||||
}
|
||||
>
|
||||
mockEmbeddableComponent
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
SaveModalComponent: jest.fn(() => <div>mockSaveModalComponent</div>),
|
||||
} as unknown as LensPublicStart;
|
||||
const mockActions: ActionTypes[] = ['addToCase', 'openInLens'];
|
||||
|
||||
describe('Embeddable', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders title', async () => {
|
||||
const { container, getByText } = render(
|
||||
<Embeddable
|
||||
appId={mockAppId}
|
||||
caseOwner={mockOwner}
|
||||
customLensAttrs={mockLensAttrs}
|
||||
customTimeRange={mockTimeRange}
|
||||
indexPatterns={mockIndexPatterns}
|
||||
lens={mockLens}
|
||||
reportType={mockReportType}
|
||||
title={mockTitle}
|
||||
withActions={mockActions}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector(`[data-test-subj="exploratoryView-title"]`)).toBeInTheDocument();
|
||||
expect(getByText(mockTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no title if it is not given', async () => {
|
||||
const { container } = render(
|
||||
<Embeddable
|
||||
appId={mockAppId}
|
||||
caseOwner={mockOwner}
|
||||
customLensAttrs={mockLensAttrs}
|
||||
customTimeRange={mockTimeRange}
|
||||
indexPatterns={mockIndexPatterns}
|
||||
lens={mockLens}
|
||||
reportType={mockReportType}
|
||||
withActions={mockActions}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="exploratoryView-title"]`)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders lens component', () => {
|
||||
const { container } = render(
|
||||
<Embeddable
|
||||
appId={mockAppId}
|
||||
caseOwner={mockOwner}
|
||||
customLensAttrs={mockLensAttrs}
|
||||
customTimeRange={mockTimeRange}
|
||||
indexPatterns={mockIndexPatterns}
|
||||
lens={mockLens}
|
||||
reportType={mockReportType}
|
||||
withActions={mockActions}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="exploratoryView-singleMetric"]`)
|
||||
).not.toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-test-subj="exploratoryView"]`)).toBeInTheDocument();
|
||||
expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].id).toEqual(
|
||||
'exploratoryView'
|
||||
);
|
||||
expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].attributes).toEqual(
|
||||
mockLensAttrs
|
||||
);
|
||||
expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].timeRange).toEqual(
|
||||
mockTimeRange
|
||||
);
|
||||
expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].timeRange).toEqual(
|
||||
mockTimeRange
|
||||
);
|
||||
expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].withDefaultActions).toEqual(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('renders single metric', () => {
|
||||
const { container } = render(
|
||||
<Embeddable
|
||||
appId={mockAppId}
|
||||
caseOwner={mockOwner}
|
||||
customLensAttrs={mockLensAttrs}
|
||||
customTimeRange={mockTimeRange}
|
||||
indexPatterns={mockIndexPatterns}
|
||||
isSingleMetric={true}
|
||||
lens={mockLens}
|
||||
reportType={mockReportType}
|
||||
withActions={mockActions}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="exploratoryView-singleMetric"]`)
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-test-subj="exploratoryView"]`)).not.toBeInTheDocument();
|
||||
expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].id).toEqual(
|
||||
'exploratoryView-singleMetric'
|
||||
);
|
||||
expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].attributes).toEqual(
|
||||
mockLensAttrs
|
||||
);
|
||||
expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].timeRange).toEqual(
|
||||
mockTimeRange
|
||||
);
|
||||
expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].withDefaultActions).toEqual(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('renders AddToCaseAction', () => {
|
||||
render(
|
||||
<Embeddable
|
||||
appId={mockAppId}
|
||||
caseOwner={mockOwner}
|
||||
customLensAttrs={mockLensAttrs}
|
||||
customTimeRange={mockTimeRange}
|
||||
indexPatterns={mockIndexPatterns}
|
||||
isSingleMetric={true}
|
||||
lens={mockLens}
|
||||
reportType={mockReportType}
|
||||
withActions={mockActions}
|
||||
/>
|
||||
);
|
||||
|
||||
expect((AddToCaseAction as jest.Mock).mock.calls[0][0].timeRange).toEqual(mockTimeRange);
|
||||
expect((AddToCaseAction as jest.Mock).mock.calls[0][0].appId).toEqual(mockAppId);
|
||||
expect((AddToCaseAction as jest.Mock).mock.calls[0][0].lensAttributes).toEqual(mockLensAttrs);
|
||||
expect((AddToCaseAction as jest.Mock).mock.calls[0][0].owner).toEqual(mockOwner);
|
||||
});
|
||||
});
|
|
@ -19,19 +19,29 @@ import { ReportConfigMap } from '../contexts/exploratory_view_config';
|
|||
import { obsvReportConfigMap } from '../obsv_exploratory_view';
|
||||
import { ActionTypes, useActions } from './use_actions';
|
||||
import { AddToCaseAction } from '../header/add_to_case_action';
|
||||
import { ViewMode } from '../../../../../../../../src/plugins/embeddable/common';
|
||||
import { observabilityFeatureId } from '../../../../../common';
|
||||
import { SingleMetric, SingleMetricOptions } from './single_metric';
|
||||
|
||||
export interface ExploratoryEmbeddableProps {
|
||||
reportType: ReportViewType;
|
||||
attributes: AllSeries;
|
||||
appId?: 'securitySolutionUI' | 'observability';
|
||||
appendTitle?: JSX.Element;
|
||||
title: string | JSX.Element;
|
||||
showCalculationMethod?: boolean;
|
||||
attributes?: AllSeries;
|
||||
axisTitlesVisibility?: XYState['axisTitlesVisibilitySettings'];
|
||||
legendIsVisible?: boolean;
|
||||
customHeight?: string | number;
|
||||
customLensAttrs?: any; // Takes LensAttributes
|
||||
customTimeRange?: { from: string; to: string }; // requred if rendered with LensAttributes
|
||||
dataTypesIndexPatterns?: Partial<Record<AppDataType, string>>;
|
||||
isSingleMetric?: boolean;
|
||||
legendIsVisible?: boolean;
|
||||
onBrushEnd?: (param: { range: number[] }) => void;
|
||||
caseOwner?: string;
|
||||
reportConfigMap?: ReportConfigMap;
|
||||
reportType: ReportViewType;
|
||||
showCalculationMethod?: boolean;
|
||||
singleMetricOptions?: SingleMetricOptions;
|
||||
title?: string | JSX.Element;
|
||||
withActions?: boolean | ActionTypes[];
|
||||
appId?: 'security' | 'observability';
|
||||
}
|
||||
|
||||
export interface ExploratoryEmbeddableComponentProps extends ExploratoryEmbeddableProps {
|
||||
|
@ -41,18 +51,25 @@ export interface ExploratoryEmbeddableComponentProps extends ExploratoryEmbeddab
|
|||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function Embeddable({
|
||||
reportType,
|
||||
attributes,
|
||||
title,
|
||||
appendTitle,
|
||||
indexPatterns,
|
||||
lens,
|
||||
appId,
|
||||
appendTitle,
|
||||
attributes = [],
|
||||
axisTitlesVisibility,
|
||||
customHeight,
|
||||
customLensAttrs,
|
||||
customTimeRange,
|
||||
indexPatterns,
|
||||
isSingleMetric = false,
|
||||
legendIsVisible,
|
||||
withActions = true,
|
||||
lens,
|
||||
onBrushEnd,
|
||||
caseOwner = observabilityFeatureId,
|
||||
reportConfigMap = {},
|
||||
reportType,
|
||||
showCalculationMethod = false,
|
||||
singleMetricOptions,
|
||||
title,
|
||||
withActions = true,
|
||||
}: ExploratoryEmbeddableComponentProps) {
|
||||
const LensComponent = lens?.EmbeddableComponent;
|
||||
const LensSaveModalComponent = lens?.SaveModalComponent;
|
||||
|
@ -60,18 +77,10 @@ export default function Embeddable({
|
|||
const [isSaveOpen, setIsSaveOpen] = useState(false);
|
||||
const [isAddToCaseOpen, setAddToCaseOpen] = useState(false);
|
||||
|
||||
const series = Object.entries(attributes)[0][1];
|
||||
const series = Object.entries(attributes)[0]?.[1];
|
||||
|
||||
const [operationType, setOperationType] = useState(series?.operationType);
|
||||
const theme = useTheme();
|
||||
const actions = useActions({
|
||||
withActions,
|
||||
attributes,
|
||||
reportType,
|
||||
appId,
|
||||
setIsSaveOpen,
|
||||
setAddToCaseOpen,
|
||||
});
|
||||
|
||||
const layerConfigs: LayerConfig[] = getLayerConfigs(
|
||||
attributes,
|
||||
|
@ -81,32 +90,52 @@ export default function Embeddable({
|
|||
{ ...reportConfigMap, ...obsvReportConfigMap }
|
||||
);
|
||||
|
||||
if (layerConfigs.length < 1) {
|
||||
return null;
|
||||
let lensAttributes;
|
||||
try {
|
||||
lensAttributes = new LensAttributes(layerConfigs);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {}
|
||||
|
||||
const attributesJSON = customLensAttrs ?? lensAttributes?.getJSON();
|
||||
const timeRange = customTimeRange ?? series?.time;
|
||||
if (typeof axisTitlesVisibility !== 'undefined') {
|
||||
(attributesJSON.state.visualization as XYState).axisTitlesVisibilitySettings =
|
||||
axisTitlesVisibility;
|
||||
}
|
||||
const lensAttributes = new LensAttributes(layerConfigs);
|
||||
|
||||
if (!LensComponent) {
|
||||
return <EuiText>No lens component</EuiText>;
|
||||
}
|
||||
|
||||
const attributesJSON = lensAttributes.getJSON();
|
||||
|
||||
(attributesJSON.state.visualization as XYState).axisTitlesVisibilitySettings =
|
||||
axisTitlesVisibility;
|
||||
|
||||
if (typeof legendIsVisible !== 'undefined') {
|
||||
(attributesJSON.state.visualization as XYState).legend.isVisible = legendIsVisible;
|
||||
}
|
||||
|
||||
const actions = useActions({
|
||||
withActions,
|
||||
attributes,
|
||||
reportType,
|
||||
appId,
|
||||
setIsSaveOpen,
|
||||
setAddToCaseOpen,
|
||||
lensAttributes: attributesJSON,
|
||||
timeRange,
|
||||
});
|
||||
|
||||
if (!attributesJSON && layerConfigs.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!LensComponent) {
|
||||
return <EuiText>No lens component</EuiText>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<EuiFlexGroup alignItems="center">
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xs">
|
||||
<h3>{title}</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<Wrapper $customHeight={customHeight}>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="none">
|
||||
{title && (
|
||||
<EuiFlexItem data-test-subj="exploratoryView-title">
|
||||
<EuiTitle size="xs">
|
||||
<h3>{title}</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{showCalculationMethod && (
|
||||
<EuiFlexItem grow={false} style={{ minWidth: 150 }}>
|
||||
<OperationTypeComponent
|
||||
|
@ -117,17 +146,37 @@ export default function Embeddable({
|
|||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{appendTitle}
|
||||
{appendTitle && appendTitle}
|
||||
</EuiFlexGroup>
|
||||
<LensComponent
|
||||
id="exploratoryView"
|
||||
style={{ height: '100%' }}
|
||||
timeRange={series?.time}
|
||||
attributes={attributesJSON}
|
||||
onBrushEnd={({ range }) => {}}
|
||||
withDefaultActions={Boolean(withActions)}
|
||||
extraActions={actions}
|
||||
/>
|
||||
|
||||
{isSingleMetric && (
|
||||
<SingleMetric {...singleMetricOptions}>
|
||||
<LensComponent
|
||||
id="exploratoryView-singleMetric"
|
||||
data-test-subj="exploratoryView-singleMetric"
|
||||
style={{ height: '100%' }}
|
||||
timeRange={timeRange}
|
||||
attributes={attributesJSON}
|
||||
onBrushEnd={onBrushEnd}
|
||||
withDefaultActions={Boolean(withActions)}
|
||||
extraActions={actions}
|
||||
viewMode={ViewMode.VIEW}
|
||||
/>
|
||||
</SingleMetric>
|
||||
)}
|
||||
{!isSingleMetric && (
|
||||
<LensComponent
|
||||
id="exploratoryView"
|
||||
data-test-subj="exploratoryView"
|
||||
style={{ height: '100%' }}
|
||||
timeRange={timeRange}
|
||||
attributes={attributesJSON}
|
||||
onBrushEnd={onBrushEnd}
|
||||
withDefaultActions={Boolean(withActions)}
|
||||
extraActions={actions}
|
||||
viewMode={ViewMode.VIEW}
|
||||
/>
|
||||
)}
|
||||
{isSaveOpen && attributesJSON && (
|
||||
<LensSaveModalComponent
|
||||
initialInput={attributesJSON as unknown as LensEmbeddableInput}
|
||||
|
@ -139,20 +188,24 @@ export default function Embeddable({
|
|||
)}
|
||||
<AddToCaseAction
|
||||
lensAttributes={attributesJSON}
|
||||
timeRange={series?.time}
|
||||
timeRange={customTimeRange ?? series?.time}
|
||||
autoOpen={isAddToCaseOpen}
|
||||
setAutoOpen={setAddToCaseOpen}
|
||||
appId={appId}
|
||||
owner={caseOwner}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
const Wrapper = styled.div<{
|
||||
$customHeight?: string | number;
|
||||
}>`
|
||||
height: 100%;
|
||||
&&& {
|
||||
> :nth-child(2) {
|
||||
height: calc(100% - 32px);
|
||||
height: ${(props) =>
|
||||
props.$customHeight ? `${props.$customHeight};` : `calc(100% - 32px);`};
|
||||
}
|
||||
.embPanel--editing {
|
||||
border-style: initial !important;
|
||||
|
|
|
@ -33,7 +33,7 @@ export function getExploratoryViewEmbeddable(
|
|||
const [indexPatterns, setIndexPatterns] = useState<IndexPatternState>({} as IndexPatternState);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const series = props.attributes[0];
|
||||
const series = props.attributes && props.attributes[0];
|
||||
|
||||
const isDarkMode = core.uiSettings.get('theme:darkMode');
|
||||
|
||||
|
@ -59,8 +59,10 @@ export function getExploratoryViewEmbeddable(
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadIndexPattern({ dataType: series.dataType });
|
||||
}, [series.dataType, loadIndexPattern]);
|
||||
if (series?.dataType) {
|
||||
loadIndexPattern({ dataType: series.dataType });
|
||||
}
|
||||
}, [series?.dataType, loadIndexPattern]);
|
||||
|
||||
if (Object.keys(indexPatterns).length === 0 || loading) {
|
||||
return <EuiLoadingSpinner />;
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { SingleMetric } from './single_metric';
|
||||
|
||||
describe('SingleMetric', () => {
|
||||
it('renders SingleMetric without icon or postfix', async () => {
|
||||
const { container } = render(<SingleMetric />);
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="single-metric-icon"]`)
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(`[data-test-subj="single-metric"]`)?.style?.maxWidth
|
||||
).toEqual(`calc(100%)`);
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="single-metric-postfix"]`)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders SingleMetric icon', async () => {
|
||||
const { container } = render(<SingleMetric metricIcon="storage" />);
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(`[data-test-subj="single-metric"]`)?.style?.maxWidth
|
||||
).toEqual(`calc(100% - 30px)`);
|
||||
expect(container.querySelector(`[data-test-subj="single-metric-icon"]`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders SingleMetric postfix', async () => {
|
||||
const { container, getByText } = render(
|
||||
<SingleMetric metricIcon="storage" metricPostfix="Host" />
|
||||
);
|
||||
expect(getByText('Host')).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(`[data-test-subj="single-metric"]`)?.style?.maxWidth
|
||||
).toEqual(`calc(100% - 30px - 150px)`);
|
||||
expect(container.querySelector(`[data-test-subj="single-metric-postfix"]`)).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(`[data-test-subj="single-metric-postfix"]`)?.style
|
||||
?.maxWidth
|
||||
).toEqual(`150px`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* 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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle, IconType } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export interface SingleMetricOptions {
|
||||
alignLnsMetric?: string;
|
||||
disableBorder?: boolean;
|
||||
disableShadow?: boolean;
|
||||
metricIcon?: IconType;
|
||||
metricIconColor?: string;
|
||||
metricIconWidth?: string;
|
||||
metricPostfix?: string;
|
||||
metricPostfixWidth?: string;
|
||||
}
|
||||
|
||||
type SingleMetricProps = SingleMetricOptions & {
|
||||
children?: JSX.Element;
|
||||
};
|
||||
|
||||
export function SingleMetric({
|
||||
alignLnsMetric = 'flex-start',
|
||||
children,
|
||||
disableBorder = true,
|
||||
disableShadow = true,
|
||||
metricIcon,
|
||||
metricIconColor,
|
||||
metricIconWidth = '30px',
|
||||
metricPostfix,
|
||||
metricPostfixWidth = '150px',
|
||||
}: SingleMetricProps) {
|
||||
let metricMaxWidth = '100%';
|
||||
metricMaxWidth = metricIcon ? `${metricMaxWidth} - ${metricIconWidth}` : metricMaxWidth;
|
||||
metricMaxWidth = metricPostfix ? `${metricMaxWidth} - ${metricPostfixWidth}` : metricMaxWidth;
|
||||
|
||||
return (
|
||||
<LensWrapper
|
||||
data-test-subj="single-metric-wrapper"
|
||||
gutterSize="none"
|
||||
$alignLnsMetric={alignLnsMetric}
|
||||
$disableBorder={disableBorder}
|
||||
$disableShadow={disableShadow}
|
||||
>
|
||||
{metricIcon && (
|
||||
<EuiFlexItem style={{ justifyContent: 'space-evenly', paddingTop: '24px' }} grow={false}>
|
||||
<EuiIcon
|
||||
type={metricIcon}
|
||||
size="l"
|
||||
color={metricIconColor}
|
||||
data-test-subj="single-metric-icon"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem
|
||||
style={{ maxWidth: `calc(${metricMaxWidth})` }}
|
||||
grow={1}
|
||||
data-test-subj="single-metric"
|
||||
>
|
||||
{children}
|
||||
</EuiFlexItem>
|
||||
{metricPostfix && (
|
||||
<EuiFlexItem
|
||||
style={{
|
||||
justifyContent: 'space-evenly',
|
||||
paddingTop: '24px',
|
||||
maxWidth: metricPostfixWidth,
|
||||
minWidth: 0,
|
||||
}}
|
||||
grow={false}
|
||||
data-test-subj="single-metric-postfix"
|
||||
>
|
||||
<StyledTitle size="m">
|
||||
<h3>{metricPostfix}</h3>
|
||||
</StyledTitle>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</LensWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTitle = styled(EuiTitle)`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const LensWrapper = styled(EuiFlexGroup)<{
|
||||
$alignLnsMetric?: string;
|
||||
$disableBorder?: boolean;
|
||||
$disableShadow?: boolean;
|
||||
}>`
|
||||
.embPanel__optionsMenuPopover {
|
||||
visibility: collapse;
|
||||
}
|
||||
.embPanel--editing {
|
||||
background-color: transparent;
|
||||
}
|
||||
${(props) =>
|
||||
props.$disableBorder
|
||||
? `.embPanel--editing {
|
||||
border: 0;
|
||||
}`
|
||||
: ''}
|
||||
&&&:hover {
|
||||
.embPanel__optionsMenuPopover {
|
||||
visibility: visible;
|
||||
}
|
||||
${(props) =>
|
||||
props.$disableShadow
|
||||
? `.embPanel--editing {
|
||||
box-shadow: none;
|
||||
}`
|
||||
: ''}
|
||||
}
|
||||
.embPanel__title {
|
||||
display: none;
|
||||
}
|
||||
${(props) =>
|
||||
props.$alignLnsMetric
|
||||
? `.lnsMetricExpression__container {
|
||||
align-items: ${props.$alignLnsMetric ?? 'flex-start'};
|
||||
}`
|
||||
: ''}
|
||||
`;
|
|
@ -15,8 +15,9 @@ import {
|
|||
Action,
|
||||
ActionExecutionContext,
|
||||
} from '../../../../../../../../src/plugins/ui_actions/public';
|
||||
import { ObservabilityAppServices } from '../../../../application/types';
|
||||
|
||||
export type ActionTypes = 'explore' | 'save' | 'addToCase';
|
||||
export type ActionTypes = 'explore' | 'save' | 'addToCase' | 'openInLens';
|
||||
|
||||
export function useActions({
|
||||
withActions,
|
||||
|
@ -25,14 +26,22 @@ export function useActions({
|
|||
setIsSaveOpen,
|
||||
setAddToCaseOpen,
|
||||
appId = 'observability',
|
||||
timeRange,
|
||||
lensAttributes,
|
||||
}: {
|
||||
withActions?: boolean | ActionTypes[];
|
||||
reportType: ReportViewType;
|
||||
attributes: AllSeries;
|
||||
appId?: 'security' | 'observability';
|
||||
appId?: 'securitySolutionUI' | 'observability';
|
||||
setIsSaveOpen: (val: boolean) => void;
|
||||
setAddToCaseOpen: (val: boolean) => void;
|
||||
timeRange: { from: string; to: string };
|
||||
lensAttributes: any;
|
||||
}) {
|
||||
const kServices = useKibana<ObservabilityAppServices>().services;
|
||||
|
||||
const { lens } = kServices;
|
||||
|
||||
const [defaultActions, setDefaultActions] = useState(['explore', 'save', 'addToCase']);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -54,6 +63,21 @@ export function useActions({
|
|||
|
||||
const routePath = createExploratoryViewRoutePath({ reportType, allSeries: attributes });
|
||||
|
||||
const openInLensCallback = useCallback(() => {
|
||||
if (lensAttributes) {
|
||||
lens.navigateToPrefilledEditor(
|
||||
{
|
||||
id: '',
|
||||
timeRange,
|
||||
attributes: lensAttributes,
|
||||
},
|
||||
{
|
||||
openInNewTab: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [lens, lensAttributes, timeRange]);
|
||||
|
||||
const exploreCallback = useCallback(() => {
|
||||
application?.navigateToApp(appId, { path: routePath });
|
||||
}, [appId, application, routePath]);
|
||||
|
@ -73,10 +97,35 @@ export function useActions({
|
|||
if (action === 'addToCase') {
|
||||
return getAddToCaseAction({ callback: addToCaseCallback });
|
||||
}
|
||||
if (action === 'openInLens') {
|
||||
return getOpenInLensAction({ callback: openInLensCallback });
|
||||
}
|
||||
return getExploreAction({ href, callback: exploreCallback });
|
||||
});
|
||||
}
|
||||
|
||||
const getOpenInLensAction = ({ callback }: { callback: () => void }): Action => {
|
||||
return {
|
||||
id: 'expViewOpenInLens',
|
||||
getDisplayName(context: ActionExecutionContext<object>): string {
|
||||
return i18n.translate('xpack.observability.expView.openInLens', {
|
||||
defaultMessage: 'Open in Lens',
|
||||
});
|
||||
},
|
||||
getIconType(context: ActionExecutionContext<object>): string | undefined {
|
||||
return 'visArea';
|
||||
},
|
||||
type: 'link',
|
||||
async isCompatible(context: ActionExecutionContext<object>): Promise<boolean> {
|
||||
return true;
|
||||
},
|
||||
async execute(context: ActionExecutionContext<object>): Promise<void> {
|
||||
callback();
|
||||
return;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getExploreAction = ({ href, callback }: { href: string; callback: () => void }): Action => {
|
||||
return {
|
||||
id: 'expViewExplore',
|
||||
|
|
|
@ -57,7 +57,12 @@ describe('AddToCaseAction', function () {
|
|||
const useAddToCaseHook = jest.spyOn(useCaseHook, 'useAddToCase');
|
||||
|
||||
const { getByText } = render(
|
||||
<AddToCaseAction lensAttributes={null} timeRange={{ to: '', from: '' }} />
|
||||
<AddToCaseAction
|
||||
lensAttributes={null}
|
||||
timeRange={{ to: '', from: '' }}
|
||||
appId="securitySolutionUI"
|
||||
owner="security"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await forNearestButton(getByText)('Add to case')).toBeDisabled();
|
||||
|
@ -69,6 +74,8 @@ describe('AddToCaseAction', function () {
|
|||
from: '',
|
||||
to: '',
|
||||
},
|
||||
appId: 'securitySolutionUI',
|
||||
owner: 'security',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
|
@ -21,19 +21,21 @@ import { observabilityFeatureId, observabilityAppId } from '../../../../../commo
|
|||
import { parseRelativeDate } from '../components/date_range_picker';
|
||||
|
||||
export interface AddToCaseProps {
|
||||
appId?: 'securitySolutionUI' | 'observability';
|
||||
autoOpen?: boolean;
|
||||
lensAttributes: TypedLensByValueInput['attributes'] | null;
|
||||
owner?: string;
|
||||
setAutoOpen?: (val: boolean) => void;
|
||||
timeRange: { from: string; to: string };
|
||||
appId?: 'security' | 'observability';
|
||||
lensAttributes: TypedLensByValueInput['attributes'] | null;
|
||||
}
|
||||
|
||||
export function AddToCaseAction({
|
||||
lensAttributes,
|
||||
timeRange,
|
||||
autoOpen,
|
||||
setAutoOpen,
|
||||
appId,
|
||||
autoOpen,
|
||||
lensAttributes,
|
||||
owner = observabilityFeatureId,
|
||||
setAutoOpen,
|
||||
timeRange,
|
||||
}: AddToCaseProps) {
|
||||
const kServices = useKibana<ObservabilityAppServices>().services;
|
||||
|
||||
|
@ -47,14 +49,14 @@ export function AddToCaseAction({
|
|||
(theCase) =>
|
||||
toMountPoint(
|
||||
<CaseToastText
|
||||
linkUrl={getUrlForApp(observabilityAppId, {
|
||||
linkUrl={getUrlForApp(appId ?? observabilityAppId, {
|
||||
deepLinkId: CasesDeepLinkId.cases,
|
||||
path: generateCaseViewPath({ detailName: theCase.id }),
|
||||
})}
|
||||
/>,
|
||||
{ theme$: theme?.theme$ }
|
||||
),
|
||||
[getUrlForApp, theme?.theme$]
|
||||
[appId, getUrlForApp, theme?.theme$]
|
||||
);
|
||||
|
||||
const absoluteFromDate = parseRelativeDate(timeRange.from);
|
||||
|
@ -68,12 +70,13 @@ export function AddToCaseAction({
|
|||
to: absoluteToDate?.toISOString() ?? '',
|
||||
},
|
||||
appId,
|
||||
owner,
|
||||
});
|
||||
|
||||
const getAllCasesSelectorModalProps: GetAllCasesSelectorModalProps = {
|
||||
onRowClick: onCaseClicked,
|
||||
userCanCrud: true,
|
||||
owner: [observabilityFeatureId],
|
||||
owner: [owner],
|
||||
onClose: () => {
|
||||
setIsCasesOpen(false);
|
||||
},
|
||||
|
|
|
@ -13,13 +13,14 @@ import { Case } from '../../../../../../cases/common';
|
|||
import { TypedLensByValueInput } from '../../../../../../lens/public';
|
||||
import { AddToCaseProps } from '../header/add_to_case_action';
|
||||
import { observabilityFeatureId } from '../../../../../common';
|
||||
import { CasesDeepLinkId } from '../../../../../../cases/public';
|
||||
import { CasesDeepLinkId, DRAFT_COMMENT_STORAGE_ID } from '../../../../../../cases/public';
|
||||
|
||||
async function addToCase(
|
||||
http: HttpSetup,
|
||||
theCase: Case,
|
||||
attributes: TypedLensByValueInput['attributes'],
|
||||
timeRange?: { from: string; to: string }
|
||||
timeRange?: { from: string; to: string },
|
||||
owner?: string
|
||||
) {
|
||||
const apiPath = `/api/cases/${theCase?.id}/comments`;
|
||||
|
||||
|
@ -31,7 +32,7 @@ async function addToCase(
|
|||
const payload = {
|
||||
comment: `!{lens${JSON.stringify(vizPayload)}}`,
|
||||
type: 'user',
|
||||
owner: observabilityFeatureId,
|
||||
owner: owner ?? observabilityFeatureId,
|
||||
};
|
||||
|
||||
return http.post(apiPath, { body: JSON.stringify(payload) });
|
||||
|
@ -42,8 +43,9 @@ export const useAddToCase = ({
|
|||
getToastText,
|
||||
timeRange,
|
||||
appId,
|
||||
owner = observabilityFeatureId,
|
||||
}: AddToCaseProps & {
|
||||
appId?: 'security' | 'observability';
|
||||
appId?: 'securitySolutionUI' | 'observability';
|
||||
getToastText: (thaCase: Case) => MountPoint<HTMLElement>;
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
@ -53,6 +55,7 @@ export const useAddToCase = ({
|
|||
http,
|
||||
application: { navigateToApp },
|
||||
notifications: { toasts },
|
||||
storage,
|
||||
} = useKibana().services;
|
||||
|
||||
const onCaseClicked = useCallback(
|
||||
|
@ -60,7 +63,7 @@ export const useAddToCase = ({
|
|||
if (theCase && lensAttributes) {
|
||||
setIsCasesOpen(false);
|
||||
setIsSaving(true);
|
||||
addToCase(http, theCase, lensAttributes, timeRange).then(
|
||||
addToCase(http, theCase, lensAttributes, timeRange, owner).then(
|
||||
() => {
|
||||
setIsSaving(false);
|
||||
toasts.addSuccess(
|
||||
|
@ -91,13 +94,26 @@ export const useAddToCase = ({
|
|||
}
|
||||
);
|
||||
} else {
|
||||
navigateToApp(appId || observabilityFeatureId, {
|
||||
/* save lens attributes and timerange to local storage,
|
||||
** so the description field will be automatically filled on case creation page.
|
||||
*/
|
||||
storage?.set(DRAFT_COMMENT_STORAGE_ID, {
|
||||
commentId: 'description',
|
||||
comment: `!{lens${JSON.stringify({
|
||||
timeRange,
|
||||
attributes: lensAttributes,
|
||||
})}}`,
|
||||
position: '',
|
||||
caseTitle: '',
|
||||
caseTags: '',
|
||||
});
|
||||
navigateToApp(appId ?? observabilityFeatureId, {
|
||||
deepLinkId: CasesDeepLinkId.casesCreate,
|
||||
openInNewTab: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
[appId, getToastText, http, lensAttributes, navigateToApp, timeRange, toasts]
|
||||
[appId, getToastText, http, lensAttributes, navigateToApp, owner, storage, timeRange, toasts]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -90,7 +90,7 @@ export { getApmTraceUrl } from './utils/get_apm_trace_url';
|
|||
export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils';
|
||||
export { ALL_VALUES_SELECTED } from './components/shared/field_value_suggestions/field_value_combobox';
|
||||
export type { AllSeries } from './components/shared/exploratory_view/hooks/use_series_storage';
|
||||
export type { SeriesUrl } from './components/shared/exploratory_view/types';
|
||||
export type { SeriesUrl, ReportViewType } from './components/shared/exploratory_view/types';
|
||||
|
||||
export type {
|
||||
ObservabilityRuleTypeFormatter,
|
||||
|
@ -99,6 +99,7 @@ export type {
|
|||
} from './rules/create_observability_rule_type_registry';
|
||||
export { createObservabilityRuleTypeRegistryMock } from './rules/observability_rule_type_registry_mock';
|
||||
export type { ExploratoryEmbeddableProps } from './components/shared/exploratory_view/embeddable/embeddable';
|
||||
export type { ActionTypes } from './components/shared/exploratory_view/embeddable/use_actions';
|
||||
|
||||
export type { AddInspectorRequest } from './context/inspector/inspector_context';
|
||||
export { InspectorContextProvider } from './context/inspector/inspector_context';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue