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

[Feature anywhere] Add annotation click handler #3777

Merged
Show file tree
Hide file tree
Changes from 14 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
3 changes: 3 additions & 0 deletions src/plugins/ui_actions/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,16 @@ export {
visualizeFieldTrigger,
VISUALIZE_GEO_FIELD_TRIGGER,
visualizeGeoFieldTrigger,
EXTERNAL_ACTION_TRIGGER,
externalActionTrigger,
} from './triggers';
export {
TriggerContextMapping,
TriggerId,
ActionContextMapping,
ActionType,
VisualizeFieldContext,
ExternalActionContext,
ACTION_VISUALIZE_FIELD,
ACTION_VISUALIZE_GEO_FIELD,
ACTION_VISUALIZE_LENS_FIELD,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
* Any modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { i18n } from '@osd/i18n';
import { Trigger } from '.';

export const EXTERNAL_ACTION_TRIGGER = 'EXTERNAL_ACTION_TRIGGER';
export const externalActionTrigger: Trigger<'EXTERNAL_ACTION_TRIGGER'> = {
id: EXTERNAL_ACTION_TRIGGER,
title: i18n.translate('uiActions.triggers.externalActionTitle', {
defaultMessage: 'Single click',
}),
description: i18n.translate('uiActions.triggers.externalActionDescription', {
defaultMessage:
'A data point click on the visualization used to trigger external action like show flyout, etc.',
}),
};
1 change: 1 addition & 0 deletions src/plugins/ui_actions/public/triggers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ export * from './value_click_trigger';
export * from './apply_filter_trigger';
export * from './visualize_field_trigger';
export * from './visualize_geo_field_trigger';
export * from './external_action_trigger';
export * from './default_trigger';
6 changes: 6 additions & 0 deletions src/plugins/ui_actions/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
APPLY_FILTER_TRIGGER,
VISUALIZE_FIELD_TRIGGER,
VISUALIZE_GEO_FIELD_TRIGGER,
EXTERNAL_ACTION_TRIGGER,
DEFAULT_TRIGGER,
} from './triggers';
import type { RangeSelectContext, ValueClickContext } from '../../embeddable/public';
Expand All @@ -51,6 +52,10 @@ export interface VisualizeFieldContext {
contextualFields?: string[];
}

export interface ExternalActionContext {
data: any;
amsiglan marked this conversation as resolved.
Show resolved Hide resolved
}

export type TriggerId = keyof TriggerContextMapping;

export type BaseContext = object;
Expand All @@ -63,6 +68,7 @@ export interface TriggerContextMapping {
[APPLY_FILTER_TRIGGER]: ApplyGlobalFilterActionContext;
[VISUALIZE_FIELD_TRIGGER]: VisualizeFieldContext;
[VISUALIZE_GEO_FIELD_TRIGGER]: VisualizeFieldContext;
[EXTERNAL_ACTION_TRIGGER]: ExternalActionContext;
}

const DEFAULT_ACTION = '';
Expand Down
8 changes: 7 additions & 1 deletion src/plugins/vis_augmenter/public/test_constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { OpenSearchDashboardsDatatable } from '../../expressions/public';
import { VIS_LAYER_COLUMN_TYPE, VisLayerTypes, HOVER_PARAM } from './';
import { VisAnnotationType } from './vega/constants';

const TEST_X_AXIS_ID = 'test-x-axis-id';
const TEST_X_AXIS_ID_DIRTY = 'test.x.axis.id';
Expand Down Expand Up @@ -489,8 +490,12 @@ const TEST_EVENTS_LAYER_SINGLE_VIS_LAYER = {
color: 'red',
filled: true,
opacity: 1,
style: [`${VisAnnotationType.POINT_IN_TIME_ANNOTATION}`],
},
transform: [{ filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0` }],
transform: [
{ filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0` },
{ calculate: `'${VisAnnotationType.POINT_IN_TIME_ANNOTATION}'`, as: 'annotationType' },
],
params: [{ name: HOVER_PARAM, select: { type: 'point', on: 'mouseover' } }],
encoding: {
x: {
Expand Down Expand Up @@ -536,6 +541,7 @@ const TEST_EVENTS_LAYER_MULTIPLE_VIS_LAYERS = {
{
filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0 || datum['${TEST_PLUGIN_RESOURCE_ID_2}'] > 0`,
},
{ calculate: `'${VisAnnotationType.POINT_IN_TIME_ANNOTATION}'`, as: 'annotationType' },
],
};

Expand Down
8 changes: 8 additions & 0 deletions src/plugins/vis_augmenter/public/vega/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

export enum VisAnnotationType {
POINT_IN_TIME_ANNOTATION = 'POINT_IN_TIME_ANNOTATION',
}
42 changes: 41 additions & 1 deletion src/plugins/vis_augmenter/public/vega/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import moment from 'moment';
import { cloneDeep, isEmpty, get } from 'lodash';
import { Item } from 'vega';
import {
OpenSearchDashboardsDatatable,
OpenSearchDashboardsDatatableColumn,
Expand All @@ -24,6 +25,7 @@ import {
VisLayers,
VisLayerTypes,
} from '../';
import { VisAnnotationType } from './constants';

// Given any visLayers, create a map to indicate which VisLayer types are present.
// Convert to an array since ES6 Maps cannot be stringified.
Expand All @@ -48,6 +50,34 @@ export const enableVisLayersInSpecConfig = (spec: object, visLayers: VisLayers):
};
};

/**
* Adds the signals which vega will use to trigger required events on the point in time annotation marks
*/
export const addVisEventSignalsToSpecConfig = (spec: object) => {
const config = get(spec, 'config', { kibana: {} });
const signals = {
...(config.kibana.signals || {}),
[`${VisAnnotationType.POINT_IN_TIME_ANNOTATION}`]: [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arent signals an array?

e.g.

  "signals": [
    {
      "name": "indexDate",
      "description": "A date value that updates in response to mousemove.",
      "update": "datetime(2005, 0, 1)",
      "on": [{"events": "mousemove", "update": "invert('xscale', x())"}]
    }
  ],

Copy link
Member

@ohltyler ohltyler May 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a signals config that isn't directly tied to the spec. It's embedded within the existing kibana field in the vega lite spec that's processed to handle OSD-specific items in the vega parser.

From my understanding signals here is just a mapping of mark ID to a signals array (that will be added to the compiled vega spec later on)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is correct @ohltyler . In the vega parser we use the key "markId" to find the mark and then add then update the signal with that mark name.

{
name: 'PointInTimeAnnotationVisEvent',
on: [
{ events: 'click', update: 'opensearchDashboardsVisEventTriggered(event, datum)' },
{ events: 'mouseover', update: 'opensearchDashboardsVisEventTriggered(event, datum)' },
{ events: 'mouseout', update: 'opensearchDashboardsVisEventTriggered(event, datum)' },
],
},
],
};

return {
...config,
kibana: {
...config.kibana,
signals,
},
};
};

// Get the first xaxis field as only 1 setup of X Axis will be supported and
// there won't be support for split series and split chart
export const getXAxisId = (
Expand Down Expand Up @@ -313,8 +343,14 @@ export const addPointInTimeEventsLayersToSpec = (
color: EVENT_COLOR,
filled: true,
opacity: 1,
// This style is only used to locate this mark when trying to add signals in the compiled vega spec.
// @see @method vega_parser._compileVegaLite
style: [`${VisAnnotationType.POINT_IN_TIME_ANNOTATION}`],
},
transform: [{ filter: generateVisLayerFilterString(visLayerColumnIds) }],
transform: [
{ filter: generateVisLayerFilterString(visLayerColumnIds) },
{ calculate: `'${VisAnnotationType.POINT_IN_TIME_ANNOTATION}'`, as: 'annotationType' },
],
params: [{ name: HOVER_PARAM, select: { type: 'point', on: 'mouseover' } }],
encoding: {
x: {
Expand All @@ -340,3 +376,7 @@ export const addPointInTimeEventsLayersToSpec = (

return newSpec;
};

export const isPointInTimeAnnotation = (item?: Item | null) => {
return item?.datum?.annotationType === VisAnnotationType.POINT_IN_TIME_ANNOTATION;
};
2 changes: 2 additions & 0 deletions src/plugins/vis_type_vega/public/data_model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { SearchResponse, SearchParams } from 'elasticsearch';
import { Filter } from 'src/plugins/data/public';
import { DslQuery } from 'src/plugins/data/common';
import { VisLayerTypes } from 'src/plugins/vis_augmenter/public';
import { Signal } from 'vega';
import { OpenSearchQueryParser } from './opensearch_query_parser';
import { EmsFileParser } from './ems_file_parser';
import { UrlParser } from './url_parser';
Expand Down Expand Up @@ -115,6 +116,7 @@ export interface OpenSearchDashboards {
type: string;
renderer: Renderer;
visibleVisLayers?: Map<VisLayerTypes, boolean>;
signals?: { [markId: string]: Signal[] };
}

export interface VegaSpec {
Expand Down
44 changes: 44 additions & 0 deletions src/plugins/vis_type_vega/public/data_model/vega_parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { euiPaletteColorBlind } from '@elastic/eui';
import { euiThemeVars } from '@osd/ui-shared-deps/theme';
import { i18n } from '@osd/i18n';
// @ts-ignore
import { Signal } from 'vega';
import { vega, vegaLite } from '../lib/vega';
import { OpenSearchQueryParser } from './opensearch_query_parser';
import { Utils } from './utils';
Expand Down Expand Up @@ -323,6 +324,49 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never
delete this.spec.autosize;
}
}

if (this._config?.signals) {
Object.entries(this._config?.signals).forEach(([markId, signals]: [string, any]) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify will this break any existing spec that uses signals?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Since we control this signals value here, as it's set by us in addVisEventSignalsToSpecConfig(). signals here isn't the actual signals field per the vega spec, but just the config field name embedded within kibana config obj. See comment above.

const mark = this.getMarkWithStyle(this.spec.marks, markId);

if (mark) {
signals.forEach((signal: Signal) => {
signal.on?.forEach((eventObj) => {
eventObj.events = `@${mark.name}:${eventObj.events}`;
});
});
this.spec.signals = (this.spec.signals || []).concat(signals);
}
});
}
}

/**
*
*/
getMarkWithStyle(marks: any[], style: string): any {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like some unit tests here would be nice to validate all the conditional branching.

if (!marks) {
return undefined;
}

if (Array.isArray(marks)) {
const markWithStyle = marks.find((mark) => {
return mark.style?.includes(style);
});

if (markWithStyle) {
return markWithStyle;
}

for (let i = 0; i < marks.length; i++) {
const res = this.getMarkWithStyle(marks[i].marks, style);
if (res) {
return res;
}
}

return undefined;
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
addPointInTimeEventsLayersToTable,
addPointInTimeEventsLayersToSpec,
enableVisLayersInSpecConfig,
addVisEventSignalsToSpecConfig,
} from '../../../vis_augmenter/public';
import { formatDatatable, createSpecFromDatatable } from './helpers';
import { VegaVisualizationDependencies } from '../plugin';
Expand Down Expand Up @@ -85,6 +86,7 @@ export const createLineVegaSpecFn = (
if (!isEmpty(pointInTimeEventsVisLayers) && dimensions.x !== null) {
spec = addPointInTimeEventsLayersToSpec(table, dimensions, spec);
spec.config = enableVisLayersInSpecConfig(spec, pointInTimeEventsVisLayers);
spec.config = addVisEventSignalsToSpecConfig(spec);
}
return JSON.stringify(spec);
},
Expand Down
14 changes: 14 additions & 0 deletions src/plugins/vis_type_vega/public/vega_view/vega_base_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const vegaFunctions = {
opensearchDashboardsRemoveFilter: 'removeFilterHandler',
opensearchDashboardsRemoveAllFilters: 'removeAllFiltersHandler',
opensearchDashboardsSetTimeFilter: 'setTimeFilterHandler',
opensearchDashboardsVisEventTriggered: 'triggerExternalActionHandler',
};

for (const funcName of Object.keys(vegaFunctions)) {
Expand All @@ -76,6 +77,7 @@ export class VegaBaseView {
this._serviceSettings = opts.serviceSettings;
this._filterManager = opts.filterManager;
this._applyFilter = opts.applyFilter;
this._triggerExternalAction = opts.externalAction;
this._timefilter = opts.timefilter;
this._view = null;
this._vegaViewConfig = null;
Expand Down Expand Up @@ -343,6 +345,18 @@ export class VegaBaseView {
this._applyFilter({ filters: [filter] });
}

/**
* This method is triggered using signal expression in vega-spec via @see opensearchDashboardsVisEventTriggered
* @param {import('vega').ScenegraphEvent} event Event triggered by the underlying vega visualization.
* @param {import('vega').Item} datum Data associated with the element on which the event was triggered.
*/
triggerExternalActionHandler(event, datum) {
this._triggerExternalAction({
event,
item: datum,
});
}

/**
* @param {object} query Query DSL snippet, as used in the query DSL editor
* @param {string} [index] as defined in OpenSearch Dashboards, or default if missing
Expand Down
1 change: 1 addition & 0 deletions src/plugins/vis_type_vega/public/vega_visualization.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const createVegaVisualization = ({ getServiceSettings }) =>
serviceSettings,
filterManager,
timefilter,
externalAction: this._vis.API.events.externalAction,
};

if (vegaParser.useMap) {
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/visualizations/public/embeddable/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,19 @@ import {
APPLY_FILTER_TRIGGER,
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
EXTERNAL_ACTION_TRIGGER,
} from '../../../ui_actions/public';

export interface VisEventToTrigger {
['applyFilter']: typeof APPLY_FILTER_TRIGGER;
['brush']: typeof SELECT_RANGE_TRIGGER;
['filter']: typeof VALUE_CLICK_TRIGGER;
['externalAction']: typeof EXTERNAL_ACTION_TRIGGER;
}

export const VIS_EVENT_TO_TRIGGER: VisEventToTrigger = {
applyFilter: APPLY_FILTER_TRIGGER,
brush: SELECT_RANGE_TRIGGER,
filter: VALUE_CLICK_TRIGGER,
externalAction: EXTERNAL_ACTION_TRIGGER,
};
5 changes: 5 additions & 0 deletions src/plugins/visualizations/public/expressions/vis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface ExprVisAPIEvents {
filter: (data: any) => void;
brush: (data: any) => void;
applyFilter: (data: any) => void;
externalAction: (data: any) => void;
}

export interface ExprVisAPI {
Expand Down Expand Up @@ -99,6 +100,10 @@ export class ExprVis extends EventEmitter {
if (!this.eventsSubject) return;
this.eventsSubject.next({ name: 'applyFilter', data });
},
externalAction: (data: any) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this planned for functional testing, or can it be unit tested?

if (!this.eventsSubject) return;
this.eventsSubject.next({ name: 'externalAction', data });
},
},
};
}
Expand Down