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:
Angela Chuang 2022-02-23 19:56:22 +00:00 committed by GitHub
parent 448436f89f
commit b0cdbd9452
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 618 additions and 77 deletions

View file

@ -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';

View file

@ -36,6 +36,7 @@
"requiredBundles": [
"data",
"dataViews",
"embeddable",
"kibanaReact",
"kibanaUtils",
"lens"

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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 />;

View file

@ -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`);
});
});

View file

@ -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'};
}`
: ''}
`;

View file

@ -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',

View file

@ -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',
})
);
});

View file

@ -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);
},

View file

@ -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 {

View file

@ -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';