diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 616ccb65493d..60eb3eb186a8 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -83,6 +83,8 @@ interface Props { SavedObjectFinder: React.ComponentType; stateTransfer?: EmbeddableStateTransfer; hideHeader?: boolean; + hasBorder?: boolean; + hasShadow?: boolean; } interface State { @@ -234,6 +236,8 @@ export class EmbeddablePanel extends React.Component { paddingSize="none" role="figure" aria-labelledby={headerId} + hasBorder={this.props.hasBorder} + hasShadow={this.props.hasShadow} > {!this.props.hideHeader && ( { getStateTransfer: (history?: ScopedHistory) => EmbeddableStateTransfer; } -export type EmbeddablePanelHOC = React.FC<{ embeddable: IEmbeddable; hideHeader?: boolean }>; +export type EmbeddablePanelHOC = React.FC<{ + embeddable: IEmbeddable; + hideHeader?: boolean; + hasBorder?: boolean; + hasShadow?: boolean; +}>; export class EmbeddablePublicPlugin implements Plugin { private readonly embeddableFactoryDefinitions: Map< @@ -168,12 +173,18 @@ export class EmbeddablePublicPlugin implements Plugin ({ embeddable, hideHeader, + hasBorder, + hasShadow, }: { embeddable: IEmbeddable; hideHeader?: boolean; + hasBorder?: boolean; + hasShadow?: boolean; }) => ( { + return { + type, + id, + name, + urlPath, + }; +}; + +export const createMockErrorEmbeddable = (): ErrorEmbeddable => { + return new ErrorEmbeddable('Oh no something has gone wrong', { id: ' 404' }); +}; + +export const createMockVisEmbeddable = ( + savedObjectId: string = SAVED_OBJ_ID, + title: string = VIS_TITLE +): VisualizeEmbeddable => { + const mockTimeFilterService = timefilterServiceMock.createStartContract(); + const mockTimeFilter = mockTimeFilterService.timefilter; + const mockVis = ({ + type: {}, + data: {}, + uiState: { + on: jest.fn(), + }, + } as unknown) as Vis; + const mockDeps = { + start: jest.fn(), + }; + const mockConfiguration = { + vis: mockVis, + editPath: 'test-edit-path', + editUrl: 'test-edit-url', + editable: true, + deps: mockDeps, + }; + const mockVisualizeInput = { id: 'test-id', savedObjectId }; + + const mockVisEmbeddable = new VisualizeEmbeddable( + mockTimeFilter, + mockConfiguration, + mockVisualizeInput + ); + mockVisEmbeddable.getTitle = () => title; + return mockVisEmbeddable; +}; + +export const createPointInTimeEventsVisLayer = ( + originPlugin: string = ORIGIN_PLUGIN, + pluginResource: PluginResource = PLUGIN_RESOURCE, + eventCount: number = EVENT_COUNT +): PointInTimeEventsVisLayer => { + const events = [] as PointInTimeEvent[]; + for (let i = 0; i < eventCount; i++) { + events.push({ + timestamp: i, + metadata: { + pluginResourceId: pluginResource.id, + }, + } as PointInTimeEvent); + } + return { + originPlugin, + type: VisLayerTypes.PointInTimeEvents, + pluginResource, + events, + }; +}; + +export const createMockEventVisEmbeddableItem = ( + savedObjectId: string = SAVED_OBJ_ID, + title: string = VIS_TITLE, + originPlugin: string = ORIGIN_PLUGIN, + pluginResource: PluginResource = PLUGIN_RESOURCE, + eventCount: number = EVENT_COUNT +): EventVisEmbeddableItem => { + const visLayer = createPointInTimeEventsVisLayer(originPlugin, pluginResource, eventCount); + const embeddable = createMockVisEmbeddable(savedObjectId, title); + return { + visLayer, + embeddable, + }; +}; diff --git a/src/plugins/vis_augmenter/public/plugin.ts b/src/plugins/vis_augmenter/public/plugin.ts index 1c064a1cee10..a6b1642f585a 100644 --- a/src/plugins/vis_augmenter/public/plugin.ts +++ b/src/plugins/vis_augmenter/public/plugin.ts @@ -5,10 +5,21 @@ import { ExpressionsSetup } from '../../expressions/public'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; import { visLayers } from './expressions'; import { setSavedAugmentVisLoader } from './services'; import { createSavedAugmentVisLoader, SavedAugmentVisLoader } from './saved_augment_vis'; +import { registerTriggersAndActions } from './ui_actions_bootstrap'; +import { UiActionsStart } from '../../ui_actions/public'; +import { + setUiActions, + setEmbeddable, + setQueryService, + setVisualizations, + setCore, +} from './services'; +import { EmbeddableStart } from '../../embeddable/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { VisualizationsStart } from '../../visualizations/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface VisAugmenterSetup {} @@ -18,12 +29,14 @@ export interface VisAugmenterStart { } export interface VisAugmenterSetupDeps { - data: DataPublicPluginSetup; expressions: ExpressionsSetup; } export interface VisAugmenterStartDeps { + uiActions: UiActionsStart; + embeddable: EmbeddableStart; data: DataPublicPluginStart; + visualizations: VisualizationsStart; } export class VisAugmenterPlugin @@ -33,13 +46,26 @@ export class VisAugmenterPlugin public setup( core: CoreSetup, - { data, expressions }: VisAugmenterSetupDeps + { expressions }: VisAugmenterSetupDeps ): VisAugmenterSetup { expressions.registerType(visLayers); return {}; } - public start(core: CoreStart, { data }: VisAugmenterStartDeps): VisAugmenterStart { + public start( + core: CoreStart, + { uiActions, embeddable, data, visualizations }: VisAugmenterStartDeps + ): VisAugmenterStart { + setUiActions(uiActions); + setEmbeddable(embeddable); + setQueryService(data.query); + setVisualizations(visualizations); + setCore(core); + + // registers the triggers & actions defined in this plugin + // also maps any triggers to possible actions + registerTriggersAndActions(core); + const savedAugmentVisLoader = createSavedAugmentVisLoader({ savedObjectsClient: core.savedObjects.client, indexPatterns: data.indexPatterns, diff --git a/src/plugins/vis_augmenter/public/services.ts b/src/plugins/vis_augmenter/public/services.ts index 00fa45374980..bed1f8182e05 100644 --- a/src/plugins/vis_augmenter/public/services.ts +++ b/src/plugins/vis_augmenter/public/services.ts @@ -3,9 +3,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { createGetterSetter } from '../../opensearch_dashboards_utils/common'; import { SavedObjectLoader } from '../../saved_objects/public'; +import { EmbeddableStart } from '../../embeddable/public'; +import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; +import { UiActionsStart } from '../../ui_actions/public'; +import { DataPublicPluginStart } from '../../../plugins/data/public'; +import { VisualizationsStart } from '../../visualizations/public'; +import { CoreStart } from '../../../core/public'; export const [getSavedAugmentVisLoader, setSavedAugmentVisLoader] = createGetterSetter< SavedObjectLoader >('savedAugmentVisLoader'); + +export const [getUiActions, setUiActions] = createGetterSetter('UIActions'); + +export const [getEmbeddable, setEmbeddable] = createGetterSetter('embeddable'); + +export const [getQueryService, setQueryService] = createGetterSetter< + DataPublicPluginStart['query'] +>('Query'); + +export const [getVisualizations, setVisualizations] = createGetterSetter( + 'visualizations' +); + +export const [getCore, setCore] = createGetterSetter('Core'); diff --git a/src/plugins/vis_augmenter/public/ui_actions_bootstrap.ts b/src/plugins/vis_augmenter/public/ui_actions_bootstrap.ts new file mode 100644 index 000000000000..9261e001b93d --- /dev/null +++ b/src/plugins/vis_augmenter/public/ui_actions_bootstrap.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart } from 'opensearch-dashboards/public'; +import { + OpenEventsFlyoutAction, + ViewEventsOptionAction, + OPEN_EVENTS_FLYOUT_ACTION, + VIEW_EVENTS_OPTION_ACTION, +} from './view_events_flyout'; +import { AugmentVisContext, openEventsFlyoutTrigger } from './view_events_flyout/triggers'; +import { OPEN_EVENTS_FLYOUT_TRIGGER } from '../../ui_actions/public'; +import { CONTEXT_MENU_TRIGGER, EmbeddableContext } from '../../embeddable/public'; +import { getUiActions } from './services'; + +// Overriding the mappings defined in UIActions plugin so that +// the new trigger and action definitions resolve +declare module '../../ui_actions/public' { + export interface TriggerContextMapping { + [OPEN_EVENTS_FLYOUT_TRIGGER]: AugmentVisContext; + } + + export interface ActionContextMapping { + [OPEN_EVENTS_FLYOUT_ACTION]: AugmentVisContext; + [VIEW_EVENTS_OPTION_ACTION]: EmbeddableContext; + } +} + +export const registerTriggersAndActions = (core: CoreStart) => { + const openEventsFlyoutAction = new OpenEventsFlyoutAction(core); + const viewEventsOptionAction = new ViewEventsOptionAction(core); + + getUiActions().registerAction(openEventsFlyoutAction); + getUiActions().registerAction(viewEventsOptionAction); + getUiActions().registerTrigger(openEventsFlyoutTrigger); + // Opening View Events flyout from the chart + getUiActions().addTriggerAction(OPEN_EVENTS_FLYOUT_TRIGGER, openEventsFlyoutAction); + // Opening View Events flyout from the context menu + getUiActions().addTriggerAction(CONTEXT_MENU_TRIGGER, viewEventsOptionAction); +}; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/index.ts b/src/plugins/vis_augmenter/public/view_events_flyout/actions/index.ts new file mode 100644 index 000000000000..cd333ed9451d --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { OPEN_EVENTS_FLYOUT_ACTION, OpenEventsFlyoutAction } from './open_events_flyout_action'; +export { VIEW_EVENTS_OPTION_ACTION, ViewEventsOptionAction } from './view_events_option_action'; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout.tsx new file mode 100644 index 000000000000..35caa41ef837 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { CoreStart } from 'src/core/public'; +import { toMountPoint } from '../../../../opensearch_dashboards_react/public'; +import { ViewEventsFlyout } from '../components'; + +interface Props { + core: CoreStart; + savedObjectId: string; +} + +export async function openViewEventsFlyout(props: Props) { + const flyoutSession = props.core.overlays.openFlyout( + toMountPoint( + { + if (flyoutSession) { + flyoutSession.close(); + } + }} + savedObjectId={props.savedObjectId} + /> + ), + { + 'data-test-subj': 'viewEventsFlyout', + ownFocus: true, + } + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.test.ts b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.test.ts new file mode 100644 index 000000000000..fe52c6ff2d36 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../../../core/public/mocks'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { OpenEventsFlyoutAction } from './open_events_flyout_action'; + +let coreStart: CoreStart; +beforeEach(async () => { + coreStart = coreMock.createStart(); +}); + +describe('OpenEventsFlyoutAction', () => { + it('is incompatible with null saved obj id', async () => { + const action = new OpenEventsFlyoutAction(coreStart); + const savedObjectId = null; + // @ts-ignore + expect(await action.isCompatible({ savedObjectId })).toBe(false); + }); + + it('is incompatible with undefined saved obj id', async () => { + const action = new OpenEventsFlyoutAction(coreStart); + const savedObjectId = undefined; + // @ts-ignore + expect(await action.isCompatible({ savedObjectId })).toBe(false); + }); + + it('is incompatible with empty saved obj id', async () => { + const action = new OpenEventsFlyoutAction(coreStart); + const savedObjectId = ''; + expect(await action.isCompatible({ savedObjectId })).toBe(false); + }); + + it('execute throws error if incompatible saved obj id', async () => { + const action = new OpenEventsFlyoutAction(coreStart); + async function check(id: any) { + await action.execute({ savedObjectId: id }); + } + await expect(check(null)).rejects.toThrow(Error); + await expect(check(undefined)).rejects.toThrow(Error); + await expect(check('')).rejects.toThrow(Error); + }); + + it('execute calls openFlyout if compatible saved obj id', async () => { + const savedObjectId = 'test-id'; + const action = new OpenEventsFlyoutAction(coreStart); + await action.execute({ savedObjectId }); + expect(coreStart.overlays.openFlyout).toHaveBeenCalledTimes(1); + }); + + it('Returns display name', async () => { + const action = new OpenEventsFlyoutAction(coreStart); + expect(action.getDisplayName()).toBeDefined(); + }); + + it('Returns undefined icon type', async () => { + const action = new OpenEventsFlyoutAction(coreStart); + expect(action.getIconType()).toBeUndefined(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts new file mode 100644 index 000000000000..fdf8596f1224 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { Action, IncompatibleActionError } from '../../../../ui_actions/public'; +import { AugmentVisContext } from '../triggers'; +import { openViewEventsFlyout } from './open_events_flyout'; + +export const OPEN_EVENTS_FLYOUT_ACTION = 'OPEN_EVENTS_FLYOUT_ACTION'; + +/** + * This action is identical to VIEW_EVENTS_OPTION_ACTION, but with different context. + * This is because the chart doesn't persist the embeddable, which is the default + * context used by the CONTEXT_MENU_TRIGGER. Because of that, we need a separate + * one that can be persisted in the chart - in this case, the AugmentVisContext, + * which is just a saved object ID. + */ + +export class OpenEventsFlyoutAction implements Action { + public readonly type = OPEN_EVENTS_FLYOUT_ACTION; + public readonly id = OPEN_EVENTS_FLYOUT_ACTION; + public order = 1; + + constructor(private core: CoreStart) {} + + public getIconType() { + return undefined; + } + + public getDisplayName() { + return i18n.translate('dashboard.actions.viewEvents.displayName', { + defaultMessage: 'View Events', + }); + } + + public async isCompatible({ savedObjectId }: AugmentVisContext) { + // checks for null / undefined / empty string + return savedObjectId ? true : false; + } + + public async execute({ savedObjectId }: AugmentVisContext) { + if (!(await this.isCompatible({ savedObjectId }))) { + throw new IncompatibleActionError(); + } + openViewEventsFlyout({ + core: this.core, + savedObjectId, + }); + } +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.test.ts b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.test.ts new file mode 100644 index 000000000000..99102b15091a --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../../../core/public/mocks'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { ViewEventsOptionAction } from './view_events_option_action'; +import { createMockErrorEmbeddable, createMockVisEmbeddable } from '../../mocks'; + +let coreStart: CoreStart; +beforeEach(async () => { + coreStart = coreMock.createStart(); +}); + +describe('ViewEventsOptionAction', () => { + // TODO: following commented out tests can be enabled when compatibility function is finalized + + // it('is incompatible with ErrorEmbeddables', async () => { + // const action = new ViewEventsOptionAction(coreStart); + // const errorEmbeddable = createMockErrorEmbeddable(); + // expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false); + // }); + + // it('is compatible with VisualizeEmbeddables', async () => { + // const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title'); + // const action = new ViewEventsOptionAction(coreStart); + // expect(await action.isCompatible({ embeddable: visEmbeddable })).toBe(true); + // }); + + // it('execute throws error if incompatible embeddable', async () => { + // const errorEmbeddable = createMockErrorEmbeddable(); + // const action = new ViewEventsOptionAction(coreStart); + // async function check() { + // await action.execute({ embeddable: errorEmbeddable }); + // } + // await expect(check()).rejects.toThrow(Error); + // }); + + it('execute calls openFlyout if compatible embeddable', async () => { + const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title'); + const action = new ViewEventsOptionAction(coreStart); + await action.execute({ embeddable: visEmbeddable }); + expect(coreStart.overlays.openFlyout).toHaveBeenCalledTimes(1); + }); + + it('Returns display name', async () => { + const action = new ViewEventsOptionAction(coreStart); + expect(action.getDisplayName()).toBeDefined(); + }); + + it('Returns an icon type', async () => { + const action = new ViewEventsOptionAction(coreStart); + expect(action.getIconType()).toBeDefined(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx new file mode 100644 index 000000000000..3ce0d4bbcb87 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { get } from 'lodash'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { VisualizeEmbeddable } from '../../../../visualizations/public'; +import { EmbeddableContext } from '../../../../embeddable/public'; +import { Action, IncompatibleActionError } from '../../../../ui_actions/public'; +import { openViewEventsFlyout } from './open_events_flyout'; + +export const VIEW_EVENTS_OPTION_ACTION = 'VIEW_EVENTS_OPTION_ACTION'; + +export class ViewEventsOptionAction implements Action { + public readonly type = VIEW_EVENTS_OPTION_ACTION; + public readonly id = VIEW_EVENTS_OPTION_ACTION; + public order = 1; + + constructor(private core: CoreStart) {} + + public getIconType(): EuiIconType { + return 'apmTrace'; + } + + public getDisplayName() { + return i18n.translate('dashboard.actions.viewEvents.displayName', { + defaultMessage: 'View Events', + }); + } + + // TODO: add the logic for compatibility here, probably from some helper fn. + // see https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3268 + public async isCompatible({ embeddable }: EmbeddableContext) { + return true; + // return embeddable instanceof VisualizeEmbeddable; + } + + public async execute({ embeddable }: EmbeddableContext) { + if (!(await this.isCompatible({ embeddable }))) { + throw new IncompatibleActionError(); + } + + const visEmbeddable = embeddable as VisualizeEmbeddable; + const savedObjectId = get(visEmbeddable.getInput(), 'savedObjectId', ''); + + openViewEventsFlyout({ + core: this.core, + savedObjectId, + }); + } +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/__snapshots__/error_flyout_body.test.tsx.snap b/src/plugins/vis_augmenter/public/view_events_flyout/components/__snapshots__/error_flyout_body.test.tsx.snap new file mode 100644 index 000000000000..7094a4660dbd --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/__snapshots__/error_flyout_body.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders component 1`] = ` +
+
+
+
+
+
+
+
+
+ oh no an error! +
+
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/__snapshots__/loading_flyout_body.test.tsx.snap b/src/plugins/vis_augmenter/public/view_events_flyout/components/__snapshots__/loading_flyout_body.test.tsx.snap new file mode 100644 index 000000000000..6b642518fc6e --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/__snapshots__/loading_flyout_body.test.tsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders component 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+`; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/base_vis_item.test.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/base_vis_item.test.tsx new file mode 100644 index 000000000000..ca88941f6f23 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/base_vis_item.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { BaseVisItem } from './base_vis_item'; +import { createMockVisEmbeddable } from '../../mocks'; + +jest.mock('../../services', () => { + return { + getEmbeddable: () => { + return { + getEmbeddablePanel: () => { + return 'MockEmbeddablePanel'; + }, + }; + }, + }; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('', () => { + it('renders', async () => { + const embeddable = createMockVisEmbeddable(); + const { getByTestId } = render(); + expect(getByTestId('baseVis')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/base_vis_item.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/base_vis_item.tsx new file mode 100644 index 000000000000..3840c5a1f23b --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/base_vis_item.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { getEmbeddable } from '../../services'; +import { VisualizeEmbeddable } from '../../../../visualizations/public'; +import './styles.scss'; + +interface Props { + embeddable: VisualizeEmbeddable; +} + +export function BaseVisItem(props: Props) { + const PanelComponent = getEmbeddable().getEmbeddablePanel(); + + return ( + + + + + + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/date_range_item.test.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/date_range_item.test.tsx new file mode 100644 index 000000000000..bd07e115d158 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/date_range_item.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { findTestSubject } from 'test_utils/helpers'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { DateRangeItem } from './date_range_item'; +import { TimeRange } from '../../../../data/common'; +import { prettyDuration } from '@elastic/eui'; +import { DATE_RANGE_FORMAT } from './view_events_flyout'; + +describe('', () => { + const mockTimeRange = { + from: 'now-7d', + to: 'now', + } as TimeRange; + const mockReloadFn = jest.fn(); + + it('time range is displayed correctly', async () => { + const prettyTimeRange = prettyDuration( + mockTimeRange.from, + mockTimeRange.to, + [], + DATE_RANGE_FORMAT + ); + + const { getByText } = render(); + expect(getByText(prettyTimeRange)).toBeInTheDocument(); + }); + + it('triggers reload on clicking on refresh button', async () => { + const component = mountWithIntl( + + ); + const refreshButton = findTestSubject(component, 'refreshButton'); + refreshButton.simulate('click'); + expect(mockReloadFn).toHaveBeenCalledTimes(1); + }); + + // Note we are not creating/comparing snapshots for this component. That is because + // it will hardcode a time-specific value which can cause failures when running + // in different envs + it('renders component', async () => { + const { getByTestId } = render( + + ); + expect(getByTestId('durationText')).toBeInTheDocument(); + expect(getByTestId('refreshButton')).toBeInTheDocument(); + expect(getByTestId('refreshDescriptionText')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/date_range_item.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/date_range_item.tsx new file mode 100644 index 000000000000..e2a7092f1e5f --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/date_range_item.tsx @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import moment from 'moment'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiIcon, + prettyDuration, + EuiButton, +} from '@elastic/eui'; +import { TimeRange } from '../../../../data/common'; +import { DATE_RANGE_FORMAT } from './view_events_flyout'; + +interface Props { + timeRange: TimeRange; + reload: () => void; +} + +export function DateRangeItem(props: Props) { + const [lastUpdatedTime, setLastUpdatedTime] = useState( + moment(Date.now()).format(DATE_RANGE_FORMAT) + ); + + const durationText = prettyDuration( + props.timeRange.from, + props.timeRange.to, + [], + DATE_RANGE_FORMAT + ); + + return ( + + + + + + {durationText} + + + { + props.reload(); + setLastUpdatedTime(moment(Date.now()).format(DATE_RANGE_FORMAT)); + }} + data-test-subj="refreshButton" + > + Refresh + + + + + {`This view is not updated to load the latest events automatically. + Last updated: ${lastUpdatedTime}`} + + + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/error_flyout_body.test.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/error_flyout_body.test.tsx new file mode 100644 index 000000000000..d3bb447ae934 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/error_flyout_body.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ErrorFlyoutBody } from './error_flyout_body'; + +describe('', () => { + const errorMsg = 'oh no an error!'; + it('shows error message', async () => { + const { getByText } = render(); + expect(getByText(errorMsg)).toBeInTheDocument(); + }); + it('renders component', async () => { + const { container, getByTestId } = render(); + expect(getByTestId('errorCallOut')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/error_flyout_body.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/error_flyout_body.tsx new file mode 100644 index 000000000000..1e0349aa18c2 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/error_flyout_body.tsx @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlyoutBody, EuiFlexGroup, EuiFlexItem, EuiCallOut } from '@elastic/eui'; + +interface Props { + errorMessage: string; +} + +export function ErrorFlyoutBody(props: Props) { + return ( + + + + + {props.errorMessage} + + + + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item.test.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item.test.tsx new file mode 100644 index 000000000000..99a865a9218c --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { EventVisItem } from './event_vis_item'; +import { + createMockEventVisEmbeddableItem, + createMockVisEmbeddable, + createPluginResource, + createPointInTimeEventsVisLayer, +} from '../../mocks'; + +jest.mock('../../services', () => { + return { + getEmbeddable: () => { + return { + getEmbeddablePanel: () => { + return 'MockEmbeddablePanel'; + }, + }; + }, + getCore: () => { + return { + http: { + basePath: { + prepend: jest.fn(), + }, + }, + }; + }, + }; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('', () => { + it('renders', async () => { + const item = createMockEventVisEmbeddableItem(); + const { getByTestId, getByText } = render(); + expect(getByTestId('eventVis')).toBeInTheDocument(); + expect(getByTestId('pluginResourceDescription')).toBeInTheDocument(); + expect(getByText(item.visLayer.pluginResource.name)).toBeInTheDocument(); + }); + + it('shows event count when rendering a PointInTimeEventsVisLayer', async () => { + const eventCount = 5; + const pluginResource = createPluginResource(); + const visLayer = createPointInTimeEventsVisLayer('test-plugin', pluginResource, eventCount); + const embeddable = createMockVisEmbeddable(); + const item = { + visLayer, + embeddable, + }; + const { getByTestId, getByText } = render(); + expect(getByTestId('eventCount')).toBeInTheDocument(); + expect(getByText(eventCount)).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item.tsx new file mode 100644 index 000000000000..bb14e252e364 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item.tsx @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { get } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiLink, EuiNotificationBadge } from '@elastic/eui'; +import { getEmbeddable, getCore } from '../../services'; +import './styles.scss'; +import { EventVisEmbeddableItem } from '.'; +import { VisLayerTypes } from '../../'; + +interface Props { + item: EventVisEmbeddableItem; +} + +export function EventVisItem(props: Props) { + const PanelComponent = getEmbeddable().getEmbeddablePanel(); + const baseUrl = getCore().http.basePath; + const { name, urlPath } = props.item.visLayer.pluginResource; + + // For now we only support PointInTimeEventsVisLayers. Ensure that check here, + // and if so, set the event count to the length of the events + const showEventCount = props.item.visLayer.type === VisLayerTypes.PointInTimeEvents; + let eventCount; + if (showEventCount) { + eventCount = get(props.item.visLayer, 'events.length', 0); + } + + return ( + <> + + + + + + {name} + + {showEventCount ? ( + + {eventCount} + + ) : null} + + + + + + + + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/events_panel.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/events_panel.tsx new file mode 100644 index 000000000000..33f3ea8bb205 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/events_panel.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import './styles.scss'; +import { EventVisEmbeddableItem, EventVisEmbeddablesMap } from '.'; +import { PluginEventsPanel } from './plugin_events_panel'; + +interface Props { + eventVisEmbeddablesMap: EventVisEmbeddablesMap; +} + +export function EventsPanel(props: Props) { + return ( + <> + {Array.from(props.eventVisEmbeddablesMap.keys()).map((key, index) => { + return ( +
+ {index !== 0 ? : null} + +
+ ); + })} + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/index.ts b/src/plugins/vis_augmenter/public/view_events_flyout/components/index.ts new file mode 100644 index 000000000000..ad96fd25af55 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + ViewEventsFlyout, + EventVisEmbeddablesMap, + EventVisEmbeddableItem, +} from './view_events_flyout'; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/loading_flyout_body.test.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/loading_flyout_body.test.tsx new file mode 100644 index 000000000000..0a06516831d5 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/loading_flyout_body.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { LoadingFlyoutBody } from './loading_flyout_body'; + +describe('', () => { + it('renders component', async () => { + const { container, getByTestId } = render(); + expect(getByTestId('loadingSpinner')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/loading_flyout_body.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/loading_flyout_body.tsx new file mode 100644 index 000000000000..90a6d5213029 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/loading_flyout_body.tsx @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlyoutBody, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +export function LoadingFlyoutBody() { + return ( + + + + + + + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/plugin_events_panel.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/plugin_events_panel.tsx new file mode 100644 index 000000000000..3c1d8a8bcf50 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/plugin_events_panel.tsx @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlexItem, EuiText } from '@elastic/eui'; +import './styles.scss'; +import { EventVisItem } from './event_vis_item'; +import { EventVisEmbeddableItem } from '.'; + +interface Props { + pluginTitle: string; + items: EventVisEmbeddableItem[]; +} + +export function PluginEventsPanel(props: Props) { + return ( + <> + + + {props.pluginTitle} + + + + {props.items.map((item, index) => ( + + ))} + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/styles.scss b/src/plugins/vis_augmenter/public/view_events_flyout/components/styles.scss new file mode 100644 index 000000000000..305c76486330 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/styles.scss @@ -0,0 +1,47 @@ +$event-vis-height: 75px; + +.view-events-flyout { + &__baseVis { + min-height: 25vh; // Visualizations require the container to have a valid width and height to render + } + + &__eventVis { + height: $event-vis-height; + } + + &__visDescription { + width: 200px; + } + + &__content { + position: absolute; + top: 110px; + right: $euiSizeM; + bottom: $euiSizeM; + left: $euiSizeM; + } + + &__contentPanel { + @include euiYScroll; + + overflow: auto; + overflow-x: hidden; + scrollbar-gutter: stable both-edges; + } +} + +.hide-y-scroll { + overflow-y: hidden; +} + +.show-y-scroll { + overflow-y: scroll; +} + +.date-range-panel-height { + height: 45px; +} + +.timeline-panel-height { + height: $event-vis-height; +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/timeline_panel.test.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/timeline_panel.test.tsx new file mode 100644 index 000000000000..a22ac5aa209c --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/timeline_panel.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TimelinePanel } from './timeline_panel'; +import { createMockVisEmbeddable } from '../../mocks'; + +jest.mock('../../services', () => { + return { + getEmbeddable: () => { + return { + getEmbeddablePanel: () => { + return 'MockEmbeddablePanel'; + }, + }; + }, + }; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('', () => { + it('renders', async () => { + const embeddable = createMockVisEmbeddable(); + const { getByTestId } = render(); + expect(getByTestId('timelineVis')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/timeline_panel.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/timeline_panel.tsx new file mode 100644 index 000000000000..910c856b39dd --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/timeline_panel.tsx @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { getEmbeddable } from '../../services'; +import './styles.scss'; +import { VisualizeEmbeddable } from '../../../../visualizations/public'; + +interface Props { + embeddable: VisualizeEmbeddable; +} + +export function TimelinePanel(props: Props) { + const PanelComponent = getEmbeddable().getEmbeddablePanel(); + return ( + + + This will be the static timeline chart + + + + + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/utils.test.ts b/src/plugins/vis_augmenter/public/view_events_flyout/components/utils.test.ts new file mode 100644 index 000000000000..39ff9d53dd44 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/utils.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createMockErrorEmbeddable } from '../../mocks'; +import { getErrorMessage } from './utils'; + +describe('utils', () => { + describe('getErrorMessage', () => { + const errorMsg = 'oh no an error!'; + it('returns message when error field is string', async () => { + const errorEmbeddable = createMockErrorEmbeddable(); + errorEmbeddable.error = errorMsg; + expect(getErrorMessage(errorEmbeddable)).toEqual(errorMsg); + }); + it('returns message when error field is Error obj', async () => { + const errorEmbeddable = createMockErrorEmbeddable(); + errorEmbeddable.error = new Error(errorMsg); + expect(getErrorMessage(errorEmbeddable)).toEqual(errorMsg); + }); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/utils.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/utils.tsx new file mode 100644 index 000000000000..f1a59d4cc23f --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/utils.tsx @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ErrorEmbeddable } from '../../../../embeddable/public'; + +export const getErrorMessage = (errorEmbeddable: ErrorEmbeddable): string => { + return errorEmbeddable.error instanceof Error + ? errorEmbeddable.error.message + : errorEmbeddable.error; +}; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/view_events_flyout.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/view_events_flyout.tsx new file mode 100644 index 000000000000..b90f508e9221 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/view_events_flyout.tsx @@ -0,0 +1,259 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { get } from 'lodash'; +import { + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyout, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { getEmbeddable, getQueryService } from '../../services'; +import './styles.scss'; +import { VisualizeEmbeddable, VisualizeInput } from '../../../../visualizations/public'; +import { TimeRange } from '../../../../data/common'; +import { BaseVisItem } from './base_vis_item'; +import { isPointInTimeEventsVisLayer, PointInTimeEventsVisLayer, VisLayer } from '../../types'; +import { DateRangeItem } from './date_range_item'; +import { LoadingFlyoutBody } from './loading_flyout_body'; +import { ErrorFlyoutBody } from './error_flyout_body'; +import { EventsPanel } from './events_panel'; +import { TimelinePanel } from './timeline_panel'; +import { ErrorEmbeddable } from '../../../../embeddable/public'; +import { getErrorMessage } from './utils'; + +interface Props { + onClose: () => void; + savedObjectId: string; +} + +export interface EventVisEmbeddableItem { + visLayer: VisLayer; + embeddable: VisualizeEmbeddable; +} + +export type EventVisEmbeddablesMap = Map; + +export const DATE_RANGE_FORMAT = 'MM/DD/YYYY HH:mm'; + +export function ViewEventsFlyout(props: Props) { + const [visEmbeddable, setVisEmbeddable] = useState(undefined); + // This map persists a plugin resource type -> a list of vis embeddables + // for each VisLayer of that type + const [eventVisEmbeddablesMap, setEventVisEmbeddablesMap] = useState< + EventVisEmbeddablesMap | undefined + >(undefined); + const [timelineVisEmbeddable, setTimelineVisEmbeddable] = useState< + VisualizeEmbeddable | undefined + >(undefined); + const [timeRange, setTimeRange] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(undefined); + + const embeddableVisFactory = getEmbeddable().getEmbeddableFactory('visualization'); + + function reload() { + visEmbeddable?.reload(); + eventVisEmbeddablesMap?.forEach((embeddableItems) => { + embeddableItems.forEach((embeddableItem) => { + embeddableItem.embeddable.reload(); + }); + }); + } + + async function fetchVisEmbeddable() { + try { + const contextInput = { + filters: getQueryService().filterManager.getFilters(), + query: getQueryService().queryString.getQuery(), + timeRange: getQueryService().timefilter.timefilter.getTime(), + visLayerResourceIds: ['test-plugin-resource-id'], + }; + setTimeRange(contextInput.timeRange); + + const embeddable = (await embeddableVisFactory?.createFromSavedObject( + props.savedObjectId, + contextInput + )) as VisualizeEmbeddable | ErrorEmbeddable; + + if (embeddable instanceof ErrorEmbeddable) { + throw getErrorMessage(embeddable); + } + + embeddable.updateInput({ + // @ts-ignore + refreshConfig: { + value: 0, + pause: true, + }, + }); + + // reload is needed so we can fetch the initial VisLayers, and so they're + // assigned to the vislayers field in the embeddable itself + embeddable.reload(); + + setVisEmbeddable(embeddable); + } catch (err: any) { + setErrorMessage(String(err)); + } + } + + // For each VisLayer in the base vis embeddable, generate a new filtered vis + // embeddable to only show datapoints for that particular VisLayer. Partition them by + // plugin resource type + async function createEventEmbeddables(embeddable: VisualizeEmbeddable) { + try { + const map = new Map() as EventVisEmbeddablesMap; + // Currently only support PointInTimeEventVisLayers. Different layer types + // may require different logic in here + const visLayers = (get(visEmbeddable, 'visLayers', []) as VisLayer[]).filter((visLayer) => + isPointInTimeEventsVisLayer(visLayer) + ) as PointInTimeEventsVisLayer[]; + if (visLayers !== undefined) { + const contextInput = { + filters: embeddable.getInput().filters, + query: embeddable.getInput().query, + timeRange: embeddable.getInput().timeRange, + }; + + await Promise.all( + visLayers.map(async (visLayer) => { + const pluginResourceType = visLayer.pluginResource.type; + const eventEmbeddable = (await embeddableVisFactory?.createFromSavedObject( + props.savedObjectId, + { + ...contextInput, + visLayerResourceIds: [visLayer.pluginResource.id as string], + } as VisualizeInput + )) as VisualizeEmbeddable | ErrorEmbeddable; + + if (eventEmbeddable instanceof ErrorEmbeddable) { + throw getErrorMessage(eventEmbeddable); + } + + eventEmbeddable.updateInput({ + // @ts-ignore + refreshConfig: { + value: 0, + pause: true, + }, + }); + + const curList = (map.get(pluginResourceType) === undefined + ? [] + : map.get(pluginResourceType)) as EventVisEmbeddableItem[]; + curList.push({ + visLayer, + embeddable: eventEmbeddable, + } as EventVisEmbeddableItem); + map.set(pluginResourceType, curList); + }) + ); + setEventVisEmbeddablesMap(map); + } + } catch (err: any) { + setErrorMessage(String(err)); + } + } + + async function createTimelineEmbeddable(embeddable: VisualizeEmbeddable) { + try { + const contextInput = { + filters: embeddable.getInput().filters, + query: embeddable.getInput().query, + timeRange: embeddable.getInput().timeRange, + // TODO: add some field in the visualize embeddable to define + // showing any data at all + }; + + const timelineEmbeddable = (await embeddableVisFactory?.createFromSavedObject( + props.savedObjectId, + contextInput + )) as VisualizeEmbeddable | ErrorEmbeddable; + + if (timelineEmbeddable instanceof ErrorEmbeddable) { + throw getErrorMessage(timelineEmbeddable); + } + + timelineEmbeddable.updateInput({ + // @ts-ignore + refreshConfig: { + value: 0, + pause: true, + }, + }); + setTimelineVisEmbeddable(timelineEmbeddable); + } catch (err: any) { + setErrorMessage(String(err)); + } + } + + useEffect(() => { + fetchVisEmbeddable(); + // TODO: look into why eslint errors here + /* eslint-disable */ + }, [props.savedObjectId]); + + useEffect(() => { + if (visEmbeddable?.visLayers) { + createEventEmbeddables(visEmbeddable); + createTimelineEmbeddable(visEmbeddable); + } + }, [visEmbeddable?.visLayers]); + + useEffect(() => { + if ( + visEmbeddable !== undefined && + eventVisEmbeddablesMap !== undefined && + timeRange !== undefined && + timelineVisEmbeddable !== undefined + ) { + setIsLoading(false); + } + }, [visEmbeddable, eventVisEmbeddablesMap, timeRange, timelineVisEmbeddable]); + + return ( + <> + + + +

{isLoading || errorMessage ? <>  : `${visEmbeddable.getTitle()}`}

+
+
+ {errorMessage ? ( + + ) : isLoading ? ( + + ) : ( + + + + + + + + + + + + + + + + + )} +
+ + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/index.ts b/src/plugins/vis_augmenter/public/view_events_flyout/index.ts new file mode 100644 index 000000000000..1e5e5b0c6b40 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './actions'; +export * from './triggers'; +export * from './components'; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/triggers/index.ts b/src/plugins/vis_augmenter/public/view_events_flyout/triggers/index.ts new file mode 100644 index 000000000000..f7a3dc2d9dce --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/triggers/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { openEventsFlyoutTrigger, AugmentVisContext } from './open_events_flyout_trigger'; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/triggers/open_events_flyout_trigger.ts b/src/plugins/vis_augmenter/public/view_events_flyout/triggers/open_events_flyout_trigger.ts new file mode 100644 index 000000000000..e8bb9b3d290b --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/triggers/open_events_flyout_trigger.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Trigger, OPEN_EVENTS_FLYOUT_TRIGGER } from '../../../../ui_actions/public'; + +export interface AugmentVisContext { + savedObjectId: string; +} + +export const openEventsFlyoutTrigger: Trigger<'OPEN_EVENTS_FLYOUT_TRIGGER'> = { + id: OPEN_EVENTS_FLYOUT_TRIGGER, + title: i18n.translate('uiActions.triggers.openEventsFlyoutTrigger', { + defaultMessage: 'Open the View Events flyout', + }), + description: i18n.translate('uiActions.triggers.openEventsFlyoutDescription', { + defaultMessage: `Opening the 'View Events' flyout`, + }), +}; diff --git a/src/plugins/vis_type_vega/opensearch_dashboards.json b/src/plugins/vis_type_vega/opensearch_dashboards.json index ca4d7020c2fa..210d4176e201 100644 --- a/src/plugins/vis_type_vega/opensearch_dashboards.json +++ b/src/plugins/vis_type_vega/opensearch_dashboards.json @@ -3,7 +3,14 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions", "inspector"], - "optionalPlugins": ["home","usageCollection"], + "requiredPlugins": [ + "data", + "visualizations", + "mapsLegacy", + "expressions", + "inspector", + "uiActions" + ], + "optionalPlugins": ["home", "usageCollection"], "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "visDefaultEditor"] } diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts index d1d5712ec7cc..e60ae9287425 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -677,7 +677,7 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never */ _setDefaultColors() { // Default category coloring to the OpenSearch color scheme - this._setDefaultValue({ scheme: 'elastic' }, 'config', 'range', 'category'); + this._setDefaultValue({ scheme: 'category10' }, 'config', 'range', 'category'); if (this.isVegaLite) { // Vega-Lite: set default color, works for fill and strike -- config: { mark: { color: '#54B399' }} diff --git a/src/plugins/vis_type_vega/public/expressions/vega_fn.ts b/src/plugins/vis_type_vega/public/expressions/vega_fn.ts index f5ab178cbd74..57549e239aed 100644 --- a/src/plugins/vis_type_vega/public/expressions/vega_fn.ts +++ b/src/plugins/vis_type_vega/public/expressions/vega_fn.ts @@ -48,6 +48,7 @@ type Output = Promise>; interface Arguments { spec: string; + savedObjectId: string; } export type VisParams = Required; @@ -81,15 +82,24 @@ export const createVegaFn = ( default: '', help: '', }, + savedObjectId: { + types: ['string'], + default: '', + help: '', + }, }, async fn(input, args, context) { const vegaRequestHandler = createVegaRequestHandler(dependencies, context); + const timeRange = get(input, 'timeRange') as TimeRange; + const query = get(input, 'query') as Query; + const filters = get(input, 'filters') as any; + const response = await vegaRequestHandler({ - timeRange: get(input, 'timeRange') as TimeRange, - query: get(input, 'query') as Query, - filters: get(input, 'filters') as any, - visParams: { spec: args.spec }, + timeRange, + query, + filters, + visParams: { spec: args.spec, savedObjectId: args.savedObjectId }, }); return { @@ -100,6 +110,7 @@ export const createVegaFn = ( visType: 'vega', visConfig: { spec: args.spec, + savedObjectId: args.savedObjectId, }, }, }; diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index 9751a73ccf91..3967c5351367 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -51,6 +51,8 @@ import { ConfigSchema } from '../config'; import { getVegaInspectorView } from './vega_inspector'; import { createLineVegaSpecFn } from './expressions/line_vega_spec_fn'; +import { UiActionsStart } from '../../ui_actions/public'; +import { setUiActions } from './services'; /** @internal */ export interface VegaVisualizationDependencies { @@ -73,6 +75,7 @@ export interface VegaPluginSetupDependencies { /** @internal */ export interface VegaPluginStartDependencies { data: DataPublicPluginStart; + uiActions: UiActionsStart; } /** @internal */ @@ -110,9 +113,10 @@ export class VegaPlugin implements Plugin, void> { visualizations.createBaseVisualization(createVegaTypeDefinition(visualizationDependencies)); } - public start(core: CoreStart, { data }: VegaPluginStartDependencies) { + public start(core: CoreStart, { data, uiActions }: VegaPluginStartDependencies) { setNotifications(core.notifications); setData(data); + setUiActions(uiActions); setInjectedMetadata(core.injectedMetadata); } } diff --git a/src/plugins/vis_type_vega/public/services.ts b/src/plugins/vis_type_vega/public/services.ts index d241b66d472c..b67a0959c63d 100644 --- a/src/plugins/vis_type_vega/public/services.ts +++ b/src/plugins/vis_type_vega/public/services.ts @@ -33,6 +33,7 @@ import { CoreStart, NotificationsStart, IUiSettingsClient } from 'src/core/publi import { DataPublicPluginStart } from '../../data/public'; import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; import { MapsLegacyConfig } from '../../maps_legacy/config'; +import { UiActionsStart } from '../../ui_actions/public'; export const [getData, setData] = createGetterSetter('Data'); @@ -40,6 +41,8 @@ export const [getNotifications, setNotifications] = createGetterSetter('UIActions'); + export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); export const [getInjectedMetadata, setInjectedMetadata] = createGetterSetter< diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index 32dcfe2c026a..5c93b20aaa8a 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -30,6 +30,7 @@ import $ from 'jquery'; import moment from 'moment'; +import { get } from 'lodash'; import dateMath from '@elastic/datemath'; import { vega, vegaLite, vegaExpressionInterpreter } from '../lib/vega'; import { Utils } from '../data_model/utils'; @@ -38,8 +39,9 @@ import { i18n } from '@osd/i18n'; import { TooltipHandler } from './vega_tooltip'; import { opensearchFilters } from '../../../data/public'; -import { getEnableExternalUrls, getData } from '../services'; +import { getEnableExternalUrls, getData, getUiActions } from '../services'; import { extractIndexPatternsFromSpec } from '../lib/extract_index_pattern'; +import { OPEN_EVENTS_FLYOUT_TRIGGER } from '../../../ui_actions/public'; vega.scheme('elastic', euiPaletteColorBlind()); @@ -84,6 +86,7 @@ export class VegaBaseView { this._destroyHandlers = []; this._initialized = false; this._enableExternalUrls = getEnableExternalUrls(); + this._visInput = opts.visInput; } async init() { @@ -281,6 +284,15 @@ export class VegaBaseView { this._addDestroyHandler(() => tthandler.hideTooltip()); } + // trigger the open events flyout UIAction if a click happens on an annotation datapoint + /* eslint-disable */ + view.addEventListener('click', function (event, item) { + // TODO: add filtering to determine if the item is a datapoint, and whether or not it is an + // "annotation" datapoint vs. regular datapoint + const { savedObjectId } = get(view, '_opensearchDashboardsView._visInput', {}); + getUiActions().getTrigger(OPEN_EVENTS_FLYOUT_TRIGGER).exec({ savedObjectId }); + }); + return view.runAsync(); // Allows callers to await rendering } } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_tooltip.js b/src/plugins/vis_type_vega/public/vega_view/vega_tooltip.js index 4cde4f8e59d7..eb640bd57529 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_tooltip.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_tooltip.js @@ -29,8 +29,6 @@ */ import { calculatePopoverPosition } from '@elastic/eui'; -import { formatValue as createTooltipContent } from 'vega-tooltip'; -import _ from 'lodash'; // Some of this code was adapted from https://github.com/vega/vega-tooltip @@ -65,17 +63,18 @@ export class TooltipHandler { view.tooltip(this.handler.bind(this)); } - /** - * The handler function. - */ + // test handler handler(view, event, item, value) { this.hideTooltip(); + //console.log('item: ', item); + // hide tooltip for null, undefined, or empty string values if (value == null || value === '') { return; } + // creating element & adding id & class attributes to it so it renders in the euiToolTip styling const el = document.createElement('div'); el.setAttribute('id', tooltipId); ['vgaVis__tooltip', 'euiToolTipPopover', 'euiToolTip', `euiToolTip--${this.position}`].forEach( @@ -87,8 +86,9 @@ export class TooltipHandler { // Sanitized HTML is created by the tooltip library, // with a large number of tests, hence suppressing eslint here. // eslint-disable-next-line no-unsanitized/property - el.innerHTML = createTooltipContent(value, _.escape, 2); - + // el.innerHTML = createTooltipContent(value, _.escape, 2); + /* eslint-disable */ + el.innerHTML = this.createTooltipHtml(value); // add to DOM to calculate tooltip size document.body.appendChild(el); @@ -117,6 +117,11 @@ export class TooltipHandler { el.setAttribute('style', `top: ${pos.top}px; left: ${pos.left}px`); } + /* eslint-disable */ + createTooltipHtml(value) { + return '

some custom tooltip

'; + } + hideTooltip() { const el = document.getElementById(tooltipId); if (el) el.remove(); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.js b/src/plugins/vis_type_vega/public/vega_visualization.js index af5c58f8a149..b7cae964ae5b 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.js @@ -29,6 +29,7 @@ */ import { i18n } from '@osd/i18n'; +import { get } from 'lodash'; import { getNotifications, getData } from './services'; export const createVegaVisualization = ({ getServiceSettings }) => @@ -83,6 +84,7 @@ export const createVegaVisualization = ({ getServiceSettings }) => const serviceSettings = await getServiceSettings(); const { filterManager } = this.dataPlugin.query; const { timefilter } = this.dataPlugin.query.timefilter; + const vegaViewParams = { parentEl: this._el, applyFilter: this._vis.API.events.applyFilter, @@ -90,6 +92,9 @@ export const createVegaVisualization = ({ getServiceSettings }) => serviceSettings, filterManager, timefilter, + visInput: { + savedObjectId: get(this._vis, 'params.savedObjectId'), + }, }; if (vegaParser.useMap) { diff --git a/src/plugins/vis_type_vislib/public/line_to_expression.ts b/src/plugins/vis_type_vislib/public/line_to_expression.ts index 26797d8348a0..4e95699ea846 100644 --- a/src/plugins/vis_type_vislib/public/line_to_expression.ts +++ b/src/plugins/vis_type_vislib/public/line_to_expression.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { get } from 'lodash'; import { buildVislibDimensions, Vis } from '../../visualizations/public'; import { buildExpression, buildExpressionFunction } from '../../expressions/public'; import { OpenSearchaggsExpressionFunctionDefinition } from '../../data/common/search/expressions'; @@ -53,6 +54,7 @@ export const toExpressionAst = async (vis: Vis, params: any) => { // spec via 'line_vega_spec' fn, then set as the arg for the final 'vega' fn const vegaFn = buildExpressionFunction('vega', { spec: vegaSpecFnExpressionBuilder, + savedObjectId: get(vis, 'id', ''), }); const ast = buildExpression([opensearchaggsFn, vegaFn]); return ast.toAst(); diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index c0d43cd521da..22b6cb91d58c 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -73,6 +73,7 @@ import { buildPipelineFromAugmentVisSavedObjs, } from '../../../vis_augmenter/public'; import { VisSavedObject } from '../types'; +import { PointInTimeEventsVisLayer, VisLayer, VisLayerTypes } from '../../../vis_augmenter/public'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -91,6 +92,7 @@ export interface VisualizeInput extends EmbeddableInput { }; savedVis?: SerializedVis; table?: unknown; + visLayerResourceIds?: string[]; } export interface VisualizeOutput extends EmbeddableOutput { @@ -136,6 +138,8 @@ export class VisualizeEmbeddable >; private savedVisualizationsLoader?: SavedVisualizationsLoader; private savedAugmentVisLoader?: SavedAugmentVisLoader; + public visLayerResourceIds?: string[]; + public visLayers?: VisLayer[]; constructor( timefilter: TimefilterContract, @@ -171,6 +175,7 @@ export class VisualizeEmbeddable this.attributeService = attributeService; this.savedVisualizationsLoader = savedVisualizationsLoader; this.savedAugmentVisLoader = savedAugmentVisLoader; + this.visLayerResourceIds = initialInput.visLayerResourceIds; this.autoRefreshFetchSubscription = timefilter .getAutoRefreshFetch$() .subscribe(this.updateHandler.bind(this)); @@ -404,7 +409,49 @@ export class VisualizeEmbeddable this.abortController = new AbortController(); const abortController = this.abortController; - const visLayers = await this.fetchVisLayers(expressionParams, abortController); + // const visLayers = await this.fetchVisLayers(expressionParams, abortController); + const visLayers = [ + { + originPlugin: 'test-plugin', + type: VisLayerTypes.PointInTimeEvents, + pluginResource: { + type: 'test-resource-type', + id: 'test-plugin-resource-id', + name: 'test-plugin-resource-name', + urlPath: 'test-plugin-resource-path', + }, + events: [ + { + timestamp: 1670918400000, + metadata: { + pluginResourceId: 'test-plugin-resource-id', + }, + }, + { + timestamp: 1672128000000, + metadata: { + pluginResourceId: 'test-plugin-resource-id', + }, + }, + { + timestamp: 1673251200000, + metadata: { + pluginResourceId: 'test-plugin-resource-id', + }, + }, + ], + }, + ] as PointInTimeEventsVisLayer[]; + + // If visLayerResourceIds is defined on the input, then filter out the found + // VisLayers to only include ones in that specified list. + // By default, do no filtering. + this.visLayers = + this.visLayerResourceIds === undefined + ? visLayers + : visLayers.filter((visLayer) => + this.visLayerResourceIds?.includes(visLayer.pluginResource.id) + ); this.expression = await buildPipeline(this.vis, { timefilter: this.timefilter, diff --git a/src/plugins/visualizations/public/expressions/vis.ts b/src/plugins/visualizations/public/expressions/vis.ts index acf747973dee..7a994a51e7d6 100644 --- a/src/plugins/visualizations/public/expressions/vis.ts +++ b/src/plugins/visualizations/public/expressions/vis.ts @@ -41,7 +41,6 @@ import { EventEmitter } from 'events'; import _ from 'lodash'; import { VisParams, PersistedState } from '../../../../plugins/visualizations/public'; - import { getTypes } from '../services'; import { VisType } from '../vis_types'; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index ffc24a81b381..957c9d8c80cd 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -41,7 +41,12 @@ export function plugin(initializerContext: PluginInitializerContext) { /** @public static code */ export { Vis } from './vis'; export { TypesService } from './vis_types/types_service'; -export { VISUALIZE_EMBEDDABLE_TYPE, VIS_EVENT_TO_TRIGGER } from './embeddable'; +export { + VISUALIZE_EMBEDDABLE_TYPE, + VIS_EVENT_TO_TRIGGER, + VisualizeEmbeddable, + DisabledLabEmbeddable, +} from './embeddable'; export { VisualizationContainer, VisualizationNoResults } from './components'; export { getSchemas as getVisSchemas, diff --git a/src/plugins/visualizations/public/legacy/build_pipeline.ts b/src/plugins/visualizations/public/legacy/build_pipeline.ts index d751e088c99d..97bc4126d662 100644 --- a/src/plugins/visualizations/public/legacy/build_pipeline.ts +++ b/src/plugins/visualizations/public/legacy/build_pipeline.ts @@ -268,8 +268,11 @@ const adjustVislibDimensionFormmaters = (vis: Vis, dimensions: { y: any[] }): vo }; export const buildPipelineVisFunction: BuildPipelineVisFunction = { - vega: (params) => { - return `vega ${prepareString('spec', params.spec)}`; + vega: (params, schemas, uiState, meta) => { + return `vega ${prepareString('spec', params.spec)} ${prepareString( + 'savedObjectId', + meta?.savedObjectId + )}`; }, input_control_vis: (params) => { return `input_control_vis ${prepareJson('visConfig', params)}`; @@ -421,11 +424,16 @@ export const buildPipeline = async (vis: Vis, params: BuildPipelineParams) => { const schemas = getSchemas(vis, params); + const meta = { + savedObjectId: get(vis, 'id', ''), + }; + if (buildPipelineVisFunction[vis.type.name]) { pipeline += buildPipelineVisFunction[vis.type.name]( { title, ...vis.params }, schemas, - uiState + uiState, + meta ); } else if (vislibCharts.includes(vis.type.name)) { const visConfig = { ...vis.params }; diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 682e678ed584..0215a86da08d 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -54,6 +54,7 @@ import { setExpressions, setUiActions, setSavedVisualizationsLoader, + setSavedAugmentVisLoader, setTimeFilter, setAggs, setChrome, @@ -92,6 +93,7 @@ import { } from './saved_visualizations/_saved_vis'; import { createSavedSearchesLoader } from '../../discover/public'; import { DashboardStart } from '../../dashboard/public'; +import { createSavedAugmentVisLoader } from '../../vis_augmenter/public'; /** * Interface for this plugin's returned setup/start contracts. @@ -209,7 +211,16 @@ export class VisualizationsPlugin chrome: core.chrome, overlays: core.overlays, }); + const savedAugmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + }); + setSavedAugmentVisLoader(savedAugmentVisLoader); setSavedSearchLoader(savedSearchLoader); + return { ...types, showNewVisModal,