diff --git a/public/action/ad_dashboard_action.tsx b/public/action/ad_dashboard_action.tsx index a845351a..417de3a9 100644 --- a/public/action/ad_dashboard_action.tsx +++ b/public/action/ad_dashboard_action.tsx @@ -14,6 +14,9 @@ import { } from '../../../../src/plugins/ui_actions/public'; import { isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { isEmpty } from 'lodash'; +import { VisualizeEmbeddable } from '../../../../src/plugins/visualizations/public'; +import { isEligibleForVisLayers } from '../../../../src/plugins/vis_augmenter/public'; export const ACTION_AD = 'ad'; @@ -57,15 +60,13 @@ export const createADAction = ({ type: ACTION_AD, grouping, isCompatible: async ({ embeddable }: ActionContext) => { - const paramsType = embeddable.vis?.params?.type; - const seriesParams = embeddable.vis?.params?.seriesParams || []; - const series = embeddable.vis?.params?.series || []; - const isLineGraph = - seriesParams.find((item) => item.type === 'line') || - series.find((item) => item.chart_type === 'line'); - const isValidVis = isLineGraph && paramsType !== 'table'; + const vis = (embeddable as VisualizeEmbeddable).vis; return Boolean( - embeddable.parent && isDashboard(embeddable.parent) && isValidVis + embeddable.parent && + isDashboard(embeddable.parent) && + vis !== undefined && + isEligibleForVisLayers(vis) && + !isEmpty((embeddable as VisualizeEmbeddable).visLayers) ); }, execute: async ({ embeddable }: ActionContext) => { diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx new file mode 100644 index 00000000..7ee94119 --- /dev/null +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/containers/__tests__/AssociatedDetectors.test.tsx @@ -0,0 +1,389 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import AssociatedDetectors from '../AssociatedDetectors'; +import { createMockVisEmbeddable } from '../../../../../../../../src/plugins/vis_augmenter/public/mocks'; +import { FLYOUT_MODES } from '../../../../../../public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants'; +import { CoreServicesContext } from '../../../../../../public/components/CoreServices/CoreServices'; +import { coreServicesMock, httpClientMock } from '../../../../../../test/mocks'; +import { + HashRouter as Router, + RouteComponentProps, + Route, + Switch, +} from 'react-router-dom'; +import { Provider } from 'react-redux'; +import configureStore from '../../../../../../public/redux/configureStore'; +import { VisualizeEmbeddable } from '../../../../../../../../src/plugins/visualizations/public'; +import { + setSavedFeatureAnywhereLoader, + setUISettings, +} from '../../../../../services'; +import { + generateAugmentVisSavedObject, + VisLayerExpressionFn, + VisLayerTypes, + createSavedAugmentVisLoader, + setUISettings as setVisAugUISettings, + getMockAugmentVisSavedObjectClient, + SavedObjectLoaderAugmentVis, +} from '../../../../../../../../src/plugins/vis_augmenter/public'; +import { getAugmentVisSavedObjs } from '../../../../../../../../src/plugins/vis_augmenter/public/utils'; +import { uiSettingsServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { + PLUGIN_AUGMENTATION_ENABLE_SETTING, + PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING, +} from '../../../../../../../../src/plugins/vis_augmenter/common'; +import userEvent from '@testing-library/user-event'; +const fn = { + type: VisLayerTypes.PointInTimeEvents, + name: 'test-fn', + args: { + testArg: 'test-value', + }, +} as VisLayerExpressionFn; +const originPlugin = 'test-plugin'; + +const uiSettingsMock = uiSettingsServiceMock.createStartContract(); +setUISettings(uiSettingsMock); +setVisAugUISettings(uiSettingsMock); +const setUIAugSettings = (isEnabled = true, maxCount = 10) => { + uiSettingsMock.get.mockImplementation((key: string) => { + if (key === PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING) return maxCount; + else if (key === PLUGIN_AUGMENTATION_ENABLE_SETTING) return isEnabled; + else return false; + }); +}; + +setUIAugSettings(); + +jest.mock('../../../../../services', () => ({ + ...jest.requireActual('../../../../../services'), + + getUISettings: () => { + return { + get: (config: string) => { + switch (config) { + case 'visualization:enablePluginAugmentation': + return true; + case 'visualization:enablePluginAugmentation.maxPluginObjects': + return 10; + default: + throw new Error( + `Accessing ${config} is not supported in the mock.` + ); + } + }, + }; + }, + getNotifications: () => { + return { + toasts: { + addDanger: jest.fn().mockName('addDanger'), + addSuccess: jest.fn().mockName('addSuccess'), + }, + }; + }, +})); + +jest.mock( + '../../../../../../../../src/plugins/vis_augmenter/public/utils', + () => ({ + getAugmentVisSavedObjs: jest.fn(), + }) +); +const visEmbeddable = createMockVisEmbeddable( + 'test-saved-obj-id', + 'test-title', + false +); + +const renderWithRouter = (visEmbeddable: VisualizeEmbeddable) => ({ + ...render( + + + + ( + + + + )} + /> + + + + ), +}); +describe('AssociatedDetectors spec', () => { + let augmentVisLoader: SavedObjectLoaderAugmentVis; + let mockDeleteFn: jest.Mock; + let detectorsToAssociate = new Array(2).fill(null).map((_, index) => { + return { + id: `detector_id_${index}`, + name: `detector_name_${index}`, + indices: [`index_${index}`], + totalAnomalies: 5, + lastActiveAnomaly: Date.now() + index, + }; + }); + //change one of the two detectors to have an ID not matching the ID in saved object + detectorsToAssociate[1].id = '5'; + + const savedObjects = new Array(2).fill(null).map((_, index) => { + const pluginResource = { + type: 'test-plugin', + id: `detector_id_${index}`, + }; + return generateAugmentVisSavedObject( + `valid-obj-id-${index}`, + fn, + `vis-id-${index}`, + originPlugin, + pluginResource + ); + }); + beforeEach(() => { + mockDeleteFn = jest.fn().mockResolvedValue('someValue'); + augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(savedObjects), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + setSavedFeatureAnywhereLoader(augmentVisLoader); + }); + describe('Renders loading component', () => { + test('renders the detector is loading', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { detectorList: [], totalDetectors: 0 }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + const { getByText } = renderWithRouter(visEmbeddable); + getByText('Loading detectors...'); + getByText('Real-time state'); + getByText('Associate a detector'); + }); + }); + + describe('renders either one or zero detectors', () => { + test('renders one associated detector', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + const { getByText, queryByText } = renderWithRouter(visEmbeddable); + getByText('Loading detectors...'); + await waitFor(() => getByText('detector_name_0')); + getByText('5'); + expect(queryByText('detector_name_1')).toBeNull(); + }, 80000); + test('renders no associated detectors', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: [detectorsToAssociate[1]], + totalDetectors: 1, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + const { getByText, findByText } = renderWithRouter(visEmbeddable); + getByText('Loading detectors...'); + await waitFor(() => + findByText( + 'There are no anomaly detectors associated with test-title visualization.', + undefined, + { timeout: 100000 } + ) + ); + }, 150000); + }); + + describe('tests unlink functionality', () => { + test('unlinks a single detector', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + const { getByText, queryByText, getAllByTestId } = + renderWithRouter(visEmbeddable); + getByText('Loading detectors...'); + await waitFor(() => getByText('detector_name_0')); + getByText('5'); + expect(queryByText('detector_name_1')).toBeNull(); + userEvent.click(getAllByTestId('unlinkButton')[0]); + await waitFor(() => + getByText( + 'Removing association unlinks detector_name_0 detector from the visualization but does not delete it. The detector association can be restored.' + ) + ); + userEvent.click(getAllByTestId('confirmUnlinkButton')[0]); + expect( + ( + await getAugmentVisSavedObjs( + 'valid-obj-id-0', + augmentVisLoader, + uiSettingsMock + ) + ).length + ).toEqual(2); + await waitFor(() => expect(mockDeleteFn).toHaveBeenCalledTimes(1)); + }, 100000); + }); +}); + +//I have a new beforeEach because I making a lot more detectors and saved objects for these tests +describe('test over 10 associated objects functionality', () => { + let augmentVisLoader: SavedObjectLoaderAugmentVis; + let mockDeleteFn: jest.Mock; + const detectorsToAssociate = new Array(16).fill(null).map((_, index) => { + const hasAnomaly = Math.random() > 0.5; + return { + id: `detector_id_${index}`, + name: `detector_name_${index}`, + indices: [`index_${index}`], + totalAnomalies: hasAnomaly ? Math.floor(Math.random() * 10) : 0, + lastActiveAnomaly: hasAnomaly ? Date.now() + index : 0, + }; + }); + + const savedObjects = new Array(16).fill(null).map((_, index) => { + const pluginResource = { + type: 'test-plugin', + id: `detector_id_${index}`, + }; + return generateAugmentVisSavedObject( + `valid-obj-id-${index}`, + fn, + `vis-id-${index}`, + originPlugin, + pluginResource + ); + }); + beforeEach(() => { + mockDeleteFn = jest.fn().mockResolvedValue('someValue'); + augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(savedObjects), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + setSavedFeatureAnywhereLoader(augmentVisLoader); + }); + test('create 20 detectors and saved objects', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + + const { getByText, queryByText, getAllByTestId, findByText } = + renderWithRouter(visEmbeddable); + + await waitFor(() => + findByText('detector_name_1', undefined, { timeout: 200000 }) + ); + expect(queryByText('detector_name_15')).toBeNull(); + // Navigate to next page + await waitFor(() => + userEvent.click(getAllByTestId('pagination-button-next')[0]) + ); + await waitFor(() => findByText('detector_name_15')); + + expect(queryByText('detector_name_0')).toBeNull(); + // Navigate to previous page + await waitFor(() => + userEvent.click(getAllByTestId('pagination-button-previous')[0]) + ); + getByText('detector_name_0'); + expect(queryByText('detector_name_15')).toBeNull(); + }, 200000); + + test('searching functionality', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + + const { queryByText, getByPlaceholderText, findByText } = + renderWithRouter(visEmbeddable); + + // initial load only first 10 detectors + await waitFor(() => + findByText('detector_name_1', undefined, { timeout: 60000 }) + ); + expect(queryByText('detector_name_15')).toBeNull(); + + //Input search event + userEvent.type(getByPlaceholderText('Search...'), 'detector_name_15'); + await waitFor(() => { + findByText('detector_name_15'); + }); + expect(queryByText('detector_name_1')).toBeNull(); + }, 100000); + + test('sorting functionality', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + detectorList: detectorsToAssociate, + totalDetectors: detectorsToAssociate.length, + }, + }); + (getAugmentVisSavedObjs as jest.Mock).mockImplementation(() => + Promise.resolve(savedObjects) + ); + + const { queryByText, getAllByTestId, findByText } = + renderWithRouter(visEmbeddable); + + // initial load only first 10 detectors (string sort means detector_name_0 -> detector_name_9 show up) + await waitFor(() => + findByText('detector_name_0', undefined, { timeout: 100000 }) + ); + expect(queryByText('detector_name_15')).toBeNull(); + + // Sort by name (string sorting) + userEvent.click(getAllByTestId('tableHeaderSortButton')[0]); + await waitFor(() => + findByText('detector_name_15', undefined, { timeout: 150000 }) + ); + expect(queryByText('detector_name_9')).toBeNull(); + }, 200000); +}); diff --git a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx index c6125537..e01a4505 100644 --- a/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx +++ b/public/components/FeatureAnywhereContextMenu/AssociatedDetectors/utils/helpers.tsx @@ -62,6 +62,7 @@ export const getColumns = ({ handleUnlinkDetectorAction }) => description: 'Remove association', icon: 'unlink', onClick: handleUnlinkDetectorAction, + 'data-test-subj': 'unlinkButton', }, ], }, diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx index 766cee4e..849f2734 100644 --- a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AddAnomalyDetector.tsx @@ -413,17 +413,11 @@ function AddAnomalyDetector({ closeFlyout(); }) .catch((error) => { - notifications.toasts.addDanger( - prettifyErrorMessage( - `Error associating selected detector: ${error}` - ) - ); + notifications.toasts.addDanger(prettifyErrorMessage(error)); }); }) .catch((error) => { - notifications.toasts.addDanger( - prettifyErrorMessage(`Error associating selected detector: ${error}`) - ); + notifications.toasts.addDanger(prettifyErrorMessage(error)); }); }; diff --git a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx index ba5e12fc..d8ee78d3 100644 --- a/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx +++ b/public/components/FeatureAnywhereContextMenu/CreateAnomalyDetector/AssociateExisting/containers/AssociateExisting.tsx @@ -206,7 +206,7 @@ export function AssociateExisting( options={options} selectedOptions={selectedOptions} onChange={(selectedOptions) => { - let detector = {} as DetectorListItem | undefined; + let detector = undefined as DetectorListItem | undefined; if (selectedOptions && selectedOptions.length) { const match = existingDetectorsAvailableToAssociate.find( @@ -234,7 +234,7 @@ export function AssociateExisting( - {renderTime(detector.enabledTime)} + Running since {renderTime(detector.enabledTime)} diff --git a/public/expressions/__tests__/overlay_anomalies.test.ts b/public/expressions/__tests__/overlay_anomalies.test.ts index d55ef2e4..c503c601 100644 --- a/public/expressions/__tests__/overlay_anomalies.test.ts +++ b/public/expressions/__tests__/overlay_anomalies.test.ts @@ -20,6 +20,7 @@ import { } from '../../pages/utils/__tests__/constants'; import { DETECTOR_HAS_BEEN_DELETED, + PLUGIN_EVENT_TYPE, START_OR_END_TIME_INVALID_ERROR, VIS_LAYER_PLUGIN_TYPE, } from '../constants'; @@ -104,6 +105,7 @@ describe('overlay_anomalies spec', () => { type: 'Anomaly Detectors', urlPath: `anomaly-detection-dashboards#/detectors/${ANOMALY_RESULT_SUMMARY_DETECTOR_ID}/results`, }, + pluginEventType: PLUGIN_EVENT_TYPE, type: 'PointInTimeEvents', }; const pointInTimeEventsVisLayer = diff --git a/public/expressions/helpers.ts b/public/expressions/helpers.ts index ed3db94b..4b76e0fb 100644 --- a/public/expressions/helpers.ts +++ b/public/expressions/helpers.ts @@ -83,7 +83,7 @@ export const convertAnomaliesToPointInTimeEventsVisLayer = ( type: VisLayerTypes.PointInTimeEvents, pluginResource: ADPluginResource, events: events, - pluginEventType: PLUGIN_EVENT_TYPE + pluginEventType: PLUGIN_EVENT_TYPE, } as PointInTimeEventsVisLayer; }; diff --git a/public/services.ts b/public/services.ts index 7e0d7843..a684a019 100644 --- a/public/services.ts +++ b/public/services.ts @@ -34,3 +34,14 @@ export const [getUiActions, setUiActions] = export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); + +// This is primarily used for mocking this module and each of its fns in tests. +export default { + getSavedFeatureAnywhereLoader, + getUISettings, + getUiActions, + getEmbeddable, + getNotifications, + getOverlays, + setUISettings, +};