Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport 2.x] Merge featureAnywhere branch into main #541

Merged
merged 1 commit into from
Jul 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions opensearch_dashboards.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,19 @@
"version": "2.9.0.0",
"opensearchDashboardsVersion": "2.9.0",
"configPath": ["anomaly_detection_dashboards"],
"requiredPlugins": ["navigation"],
"optionalPlugins": [],
"requiredPlugins": [
"opensearchDashboardsUtils",
"expressions",
"data",
"visAugmenter",
"uiActions",
"dashboard",
"embeddable",
"opensearchDashboardsReact",
"savedObjects",
"visAugmenter",
"opensearchDashboardsUtils"
],
"server": true,
"ui": true
}
78 changes: 78 additions & 0 deletions public/action/ad_dashboard_action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/
import { IEmbeddable } from '../../../../src/plugins/dashboard/public/embeddable_plugin';
import {
DASHBOARD_CONTAINER_TYPE,
DashboardContainer,
} from '../../../../src/plugins/dashboard/public';
import {
IncompatibleActionError,
createAction,
Action,
} from '../../../../src/plugins/ui_actions/public';
import { isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public';
import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
import { VisualizeEmbeddable } from '../../../../src/plugins/visualizations/public';
import { isEligibleForVisLayers } from '../../../../src/plugins/vis_augmenter/public';
import { getUISettings } from '../services';

export const ACTION_AD = 'ad';

function isDashboard(
embeddable: IEmbeddable
): embeddable is DashboardContainer {
return embeddable.type === DASHBOARD_CONTAINER_TYPE;
}

export interface ActionContext {
embeddable: IEmbeddable;
}

export interface CreateOptions {
grouping: Action['grouping'];
title: string;
icon: EuiIconType;
id: string;
order: number;
onClick: Function;
}

export const createADAction = ({
grouping,
title,
icon,
id,
order,
onClick,
}: CreateOptions) =>
createAction({
id,
order,
getDisplayName: ({ embeddable }: ActionContext) => {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}
return title;
},
getIconType: () => icon,
type: ACTION_AD,
grouping,
isCompatible: async ({ embeddable }: ActionContext) => {
const vis = (embeddable as VisualizeEmbeddable).vis;
return Boolean(
embeddable.parent &&
embeddable.getInput()?.viewMode === 'view' &&
isDashboard(embeddable.parent) &&
vis !== undefined &&
isEligibleForVisLayers(vis, getUISettings())
);
},
execute: async ({ embeddable }: ActionContext) => {
if (!isReferenceOrValueEmbeddable(embeddable)) {
throw new IncompatibleActionError();
}
onClick({ embeddable });
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { get } from 'lodash';
import AssociatedDetectors from '../AssociatedDetectors/containers/AssociatedDetectors';
import { getEmbeddable } from '../../../../public/services';
import AddAnomalyDetector from '../CreateAnomalyDetector';
import { FLYOUT_MODES } from './constants';

const AnywhereParentFlyout = ({ startingFlyout, ...props }) => {
const embeddable = getEmbeddable().getEmbeddableFactory;
const indices: { label: string }[] = [
{ label: get(embeddable, 'vis.data.indexPattern.title', '') },
];

const [mode, setMode] = useState(startingFlyout);
const [selectedDetector, setSelectedDetector] = useState(undefined);

const AnywhereFlyout = {
[FLYOUT_MODES.create]: AddAnomalyDetector,
[FLYOUT_MODES.associated]: AssociatedDetectors,
[FLYOUT_MODES.existing]: AddAnomalyDetector,
}[mode];

return (
<AnywhereFlyout
{...{
...props,
setMode,
mode,
indices,
selectedDetector,
setSelectedDetector,
}}
/>
);
};

export default AnywhereParentFlyout;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

//created: Flyout for creating a new anomaly detector from a visualization
//associated: Flyout for listing all the associated detectors to the given visualization
//existing: Flyout for associating existing detectors with the current visualizations
export enum FLYOUT_MODES {
create = 'create',
associated = 'associated',
existing = 'existing',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import AnywhereParentFlyout from './AnywhereParentFlyout';

export default AnywhereParentFlyout;
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useState } from 'react';
import {
EuiText,
EuiOverlayMask,
EuiButton,
EuiButtonEmpty,
EuiModal,
EuiModalHeader,
EuiModalFooter,
EuiModalBody,
EuiModalHeaderTitle,
} from '@elastic/eui';
import { DetectorListItem } from '../../../../../models/interfaces';
import { EuiSpacer } from '@elastic/eui';

interface ConfirmUnlinkDetectorModalProps {
detector: DetectorListItem;
onUnlinkDetector(): void;
onHide(): void;
onConfirm(): void;
isListLoading: boolean;
}

export const ConfirmUnlinkDetectorModal = (
props: ConfirmUnlinkDetectorModalProps
) => {
const [isModalLoading, setIsModalLoading] = useState<boolean>(false);
const isLoading = isModalLoading || props.isListLoading;
return (
<EuiOverlayMask>
<EuiModal
data-test-subj="unlinkDetectorsModal"
onClose={props.onHide}
maxWidth={450}
>
<EuiModalHeader>
<EuiModalHeaderTitle>{'Remove association?'}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText>
Removing association unlinks {props.detector.name} detector from the
visualization but does not delete it. The detector association can
be restored.
</EuiText>
<EuiSpacer size="s" />
</EuiModalBody>
<EuiModalFooter>
{isLoading ? null : (
<EuiButtonEmpty
data-test-subj="cancelUnlinkButton"
onClick={props.onHide}
>
Cancel
</EuiButtonEmpty>
)}
<EuiButton
data-test-subj="confirmUnlinkButton"
color="primary"
fill
isLoading={isLoading}
onClick={async () => {
setIsModalLoading(true);
props.onUnlinkDetector();
props.onConfirm();
}}
>
{'Remove association'}
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
import React from 'react';

const FILTER_TEXT = 'There are no detectors matching your search';

interface EmptyDetectorProps {
isFilterApplied: boolean;
embeddableTitle: string;
}

export const EmptyAssociatedDetectorMessage = (props: EmptyDetectorProps) => (
<EuiEmptyPrompt
title={<h3>No anomaly detectors to display</h3>}
titleSize="s"
data-test-subj="emptyAssociatedDetectorFlyoutMessage"
style={{ maxWidth: '45em' }}
body={
<EuiText>
<p>
{props.isFilterApplied
? FILTER_TEXT
: `There are no anomaly detectors associated with ${props.embeddableTitle} visualization.`}
</p>
</EuiText>
}
/>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { findAllByTestId, render, waitFor } from '@testing-library/react';
import { ConfirmUnlinkDetectorModal } from '../index';
import { getRandomDetector } from '../../../../../../public/redux/reducers/__tests__/utils';
import { DetectorListItem } from '../../../../../../public/models/interfaces';
import userEvent from '@testing-library/user-event';

describe('ConfirmUnlinkDetectorModal spec', () => {
beforeEach(() => {
jest.clearAllMocks();
});

const testDetectors = [
{
id: 'detectorId1',
name: 'test-detector-1',
},
{
id: 'detectorId2',
name: 'test-detector-2',
},
] as DetectorListItem[];

const ConfirmUnlinkDetectorModalProps = {
detector: testDetectors[0],
onHide: jest.fn(),
onConfirm: jest.fn(),
onUnlinkDetector: jest.fn(),
isListLoading: false,
};

test('renders the component correctly', () => {
const { container, getByText } = render(
<ConfirmUnlinkDetectorModal {...ConfirmUnlinkDetectorModalProps} />
);
getByText('Remove association?');
getByText(
'Removing association unlinks test-detector-1 detector from the visualization but does not delete it. The detector association can be restored.'
);
});
test('should call onConfirm() when closing', async () => {
const { container, getByText, getByTestId } = render(
<ConfirmUnlinkDetectorModal {...ConfirmUnlinkDetectorModalProps} />
);
getByText('Remove association?');
userEvent.click(getByTestId('confirmUnlinkButton'));
expect(ConfirmUnlinkDetectorModalProps.onConfirm).toHaveBeenCalled();
});
test('should call onConfirm() when closing', async () => {
const { container, getByText, getByTestId } = render(
<ConfirmUnlinkDetectorModal {...ConfirmUnlinkDetectorModalProps} />
);
getByText('Remove association?');
userEvent.click(getByTestId('confirmUnlinkButton'));
expect(ConfirmUnlinkDetectorModalProps.onConfirm).toHaveBeenCalled();
});
test('should call onHide() when closing', async () => {
const { getByTestId } = render(
<ConfirmUnlinkDetectorModal {...ConfirmUnlinkDetectorModalProps} />
);
userEvent.click(getByTestId('cancelUnlinkButton'));
expect(ConfirmUnlinkDetectorModalProps.onHide).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { findAllByTestId, render, waitFor } from '@testing-library/react';
import { EmptyAssociatedDetectorMessage } from '../index';
import { getRandomDetector } from '../../../../../../public/redux/reducers/__tests__/utils';
import { DetectorListItem } from '../../../../../../public/models/interfaces';
import userEvent from '@testing-library/user-event';

describe('ConfirmUnlinkDetectorModal spec', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('renders the component with filter applied', () => {
const { container, getByText } = render(
<EmptyAssociatedDetectorMessage
isFilterApplied={true}
embeddableTitle="test-title"
/>
);
getByText('There are no detectors matching your search');
expect(container).toMatchSnapshot();
});
test('renders the component with filter applied', () => {
const { container, getByText } = render(
<EmptyAssociatedDetectorMessage
isFilterApplied={false}
embeddableTitle="test-title"
/>
);
getByText(
'There are no anomaly detectors associated with test-title visualization.'
);
expect(container).toMatchSnapshot();
});
});
Loading
Loading