diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f77fd00b8..d54edc04772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Bump OUI to `1.1.2` to make `anomalyDetection` icon available ([#4408](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4408)) - Add `color-scheme` to the root styling ([#4477](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4477)) - [Multiple DataSource] Frontend support for adding sample data ([#4412](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4412)) +- Enable plugins to augment visualizations with additional data and context ([#4361](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4361)) ### 🐛 Bug Fixes diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index d7e0d390b0f..73f31233a78 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -264,3 +264,7 @@ # Set the value of this setting to false to hide the help menu link to the OpenSearch Dashboards user survey # opensearchDashboards.survey.url: "https://survey.opensearch.org" + +# Set the value of this setting to true to enable plugin augmentation on Dashboard +# vis_augmenter.pluginAugmentationEnabled: true + diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 616ccb65493..5a340d3a701 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -83,6 +83,10 @@ interface Props { SavedObjectFinder: React.ComponentType; stateTransfer?: EmbeddableStateTransfer; hideHeader?: boolean; + // TODO: the below hasBorder and hasShadow fields may be removed as part of + // https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4483 + hasBorder?: boolean; + hasShadow?: boolean; } interface State { @@ -234,6 +238,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; }) => ( = { 'visualization:regionmap:showWarnings': { type: 'boolean' }, 'visualization:dimmingOpacity': { type: 'float' }, 'visualization:tileMap:maxPrecision': { type: 'long' }, + 'visualization:enablePluginAugmentation': { type: 'boolean' }, + 'visualization:enablePluginAugmentation.maxPluginObjects': { type: 'number' }, 'securitySolution:ipReputationLinks': { type: 'text' }, 'csv:separator': { type: 'keyword' }, 'visualization:tileMap:WMSdefaults': { type: 'text' }, diff --git a/src/plugins/saved_objects/README.md b/src/plugins/saved_objects/README.md index 3b6dc4f7a79..2f7d98dbb36 100644 --- a/src/plugins/saved_objects/README.md +++ b/src/plugins/saved_objects/README.md @@ -2,77 +2,78 @@ The saved object plugin provides all the core services and functionalities of saved objects. It is utilized by many core plugins such as [`visualization`](../visualizations/), [`dashboard`](../dashboard/) and [`visBuilder`](../vis_builder/), as well as external plugins. Saved object is the primary way to store app and plugin data in a standardized form in OpenSearch Dashboards. They allow plugin developers to manage creating, saving, editing and retrieving data for the application. They can also make reference to other saved objects and have useful features out of the box, such as migrations and strict typings. The saved objects can be managed by the Saved Object Management UI. -## Save relationships to index pattern +### Relationships -Saved objects that have relationships to index patterns are saved using the [`kibanaSavedObjectMeta`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts#L59) attribute and the [`references`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts#L60) array structure. Functions from the data plugin are used by the saved object plugin to manage this index pattern relationship. +Saved objects can persist parent/child relationships to other saved objects via `references`. These relationships can be viewed on the UI in the [saved objects management plugin](src/core/server/saved_objects_management/README.md). Relationships can be useful to combine existing saved objects to produce new ones, such as using an index pattern as the source for a visualization, or a dashboard consisting of many visualizations. -A standard saved object and its index pattern relationship: +Some saved object fields have pre-defined logic. For example, if a saved object type has a `searchSource` field indicating an index pattern relationship, a reference will automatically be created using the [`kibanaSavedObjectMeta`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts#L59) attribute and the [`references`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts#L60) array structure. Functions from the data plugin are used by the saved object plugin to manage this index pattern relationship. + +An example of a visualization saved object and its index pattern relationship: ```ts "kibanaSavedObjectMeta" : { "searchSourceJSON" : """{"filter":[],"query":{"query":"","language":"kuery"},"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}""" - } - }, - "type" : "visualization", - "references" : [ - { - "name" : "kibanaSavedObjectMeta.searchSourceJSON.index", - "type" : "index-pattern", - "id" : "90943e30-9a47-11e8-b64d-95841ca0b247" - } - ], +} +"type" : "visualization", +"references" : [ + { + "name" : "kibanaSavedObjectMeta.searchSourceJSON.index", + "type" : "index-pattern", + "id" : "90943e30-9a47-11e8-b64d-95841ca0b247" + } +], ``` ### Saving a saved object -When saving a saved object and its relationship to the index pattern: +When saving a saved object and its relationship to the index pattern: 1. A saved object will be built using [`buildSavedObject`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/build_saved_object.ts#L46) function. Services such as hydrating index pattern, initializing and serializing the saved object are set, and configs such as saved object id, migration version are defined. -2. The saved object will then be serialized by three steps: - - a. By using [`extractReferences`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/data/common/search/search_source/extract_references.ts#L35) function from the data plugin, the index pattern information will be extracted using the index pattern id within the `kibanaSavedObjectMeta`, and the id will be replaced by a reference name, such as `indexRefName`. A corresponding index pattern object will then be created to include more detailed information of the index pattern: name (`kibanaSavedObjectMeta.searchSourceJSON.index`), type, and id. - - ```ts - let searchSourceFields = { ...state }; - const references = []; - - if (searchSourceFields.index) { - const indexId = searchSourceFields.index.id || searchSourceFields.index; - const refName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; - references.push({ - name: refName, - type: 'index-pattern', - id: indexId - }); - searchSourceFields = { ...searchSourceFields, - indexRefName: refName, - index: undefined - }; - } - ``` +2. The saved object will then be serialized by three steps: - b. The `indexRefName` along with other information will be stringified and saved into `kibanaSavedObjectMeta.searchSourceJSON`. - - c. Saved object client will create the reference array attribute, and the index pattern object will be pushed into the reference array. + a. By using [`extractReferences`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/data/common/search/search_source/extract_references.ts#L35) function from the data plugin, the index pattern information will be extracted using the index pattern id within the `kibanaSavedObjectMeta`, and the id will be replaced by a reference name, such as `indexRefName`. A corresponding index pattern object will then be created to include more detailed information of the index pattern: name (`kibanaSavedObjectMeta.searchSourceJSON.index`), type, and id. + ```ts + let searchSourceFields = { ...state }; + const references = []; + + if (searchSourceFields.index) { + const indexId = searchSourceFields.index.id || searchSourceFields.index; + const refName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; + references.push({ + name: refName, + type: 'index-pattern', + id: indexId, + }); + searchSourceFields = { ...searchSourceFields, indexRefName: refName, index: undefined }; + } + ``` + + b. The `indexRefName` along with other information will be stringified and saved into `kibanaSavedObjectMeta.searchSourceJSON`. + + c. Saved object client will create the reference array attribute, and the index pattern object will be pushed into the reference array. ### Loading an existing or creating a new saved object -1. When loading an existing object or creating a new saved object, [`initializeSavedObject`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts#L38) function will be called. +1. When loading an existing object or creating a new saved object, [`initializeSavedObject`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts#L38) function will be called. 2. The saved object will be deserialized in the [`applyOpenSearchResp`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/apply_opensearch_resp.ts#L50) function. - a. Using [`injectReferences`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/data/common/search/search_source/inject_references.ts#L34) function from the data plugin, the index pattern reference name within the `kibanaSavedObject` will be substituted by the index pattern id and the corresponding index pattern reference object will be deleted if filters are applied. + a. Using [`injectReferences`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/data/common/search/search_source/inject_references.ts#L34) function from the data plugin, the index pattern reference name within the `kibanaSavedObject` will be substituted by the index pattern id and the corresponding index pattern reference object will be deleted if filters are applied. + + ```ts + searchSourceReturnFields.index = reference.id; + delete searchSourceReturnFields.indexRefName; + ``` - ```ts - searchSourceReturnFields.index = reference.id; - delete searchSourceReturnFields.indexRefName; - ``` +### Creating a new saved object type -### Others - -If a saved object type wishes to have additional custom functionalities when extracting/injecting references, or after OpenSearch's response, it can define functions in the class constructor when extending the `SavedObjectClass`. For example, visualization plugin's [`SavedVis`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts#L91) class has additional `extractReferences`, `injectReferences` and `afterOpenSearchResp` functions defined in [`_saved_vis.ts`](../visualizations/public/saved_visualizations/_saved_vis.ts). +Steps need to be done on both the public/client-side & the server-side for creating a new saved object type. + +Client-side: + +1. Define a class that extends `SavedObjectClass`. This is where custom functionalities, such as extracting/injecting references, or overriding `afterOpenSearchResp` can be set in the constructor. For example, visualization plugin's [`SavedVis`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts#L91) class has additional `extractReferences`, `injectReferences` and `afterOpenSearchResp` functions defined in [`_saved_vis.ts`](../visualizations/public/saved_visualizations/_saved_vis.ts), and set in the `SavedVis` constructor. ```ts class SavedVis extends SavedObjectClass { @@ -85,14 +86,71 @@ class SavedVis extends SavedObjectClass { afterOpenSearchResp: async (savedObject: SavedObject) => { const savedVis = (savedObject as any) as ISavedVis; ... ... - + return (savedVis as any) as SavedObject; }, ``` +2. Optionally create a loader class that extends `SavedObjectLoader`. This can be useful for performing default CRUD operations on this particular saved object type, as well as overriding default utility functions like `find`. For example, the `visualization` saved object overrides `mapHitSource` (used in `find` & `findAll`) to do additional checking on the returned source object, such as if the returned type is valid: + +```ts +class SavedObjectLoaderVisualize extends SavedObjectLoader { + mapHitSource = (source: Record, id: string) => { + const visTypes = visualizationTypes; + ... ... + let typeName = source.typeName; + if (source.visState) { + try { + typeName = JSON.parse(String(source.visState)).type; + } catch (e) { + /* missing typename handled below */ + } + } + + if (!typeName || !visTypes.get(typeName)) { + source.error = 'Unknown visualization type'; + return source; + } + ... ... + return source; + }; +``` + +The loader can then be instantiated once and referenced when needed. For example, the `visualizations` plugin creates and sets it in its `services` in the plugin's start lifecycle: + +```ts +public start( + core: CoreStart, + { data, expressions, uiActions, embeddable, dashboard }: VisualizationsStartDeps +): VisualizationsStart { + ... ... + const savedVisualizationsLoader = createSavedVisLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + visualizationTypes: types, + }); + setSavedVisualizationsLoader(savedVisualizationsLoader); + ... ... +} +``` + +Server-side: + +1. Define the new type that is of type `SavedObjectsType`, which is where various settings can be configured, including the index mappings when the object is stored in the system index. To see an example type definition, you can refer to the [visualization saved object type](src/plugins/visualizations/server/saved_objects/visualization.ts). +2. Register the new type in the respective plugin's setup lifecycle function. For example, the `visualizations` plugin registers the `visualization` saved object type like below: + +```ts +core.savedObjects.registerType(visualizationSavedObjectType); +``` + +To make the new type manageable in the `saved_objects_management` plugin, refer to the [plugin README](src/plugins/saved_objects_management/README.md) + ## Migration -When a saved object is created using a previous version, the migration will trigger if there is a new way of saving the saved object and the migration functions alter the structure of the old saved object to follow the new structure. Migrations can be defined in the specific saved object type in the plugin's server folder. For example, +When a saved object is created using a previous version, the migration will trigger if there is a new way of saving the saved object and the migration functions alter the structure of the old saved object to follow the new structure. Migrations can be defined in the specific saved object type in the plugin's server folder. For example, ```ts export const visualizationSavedObjectType: SavedObjectsType = { @@ -116,4 +174,4 @@ The migraton version will be saved as a `migrationVersion` attribute in the save }, ``` -For a more detailed explanation on the migration, refer to [`saved objects management`](src/core/server/saved_objects/migrations/README.md). \ No newline at end of file +For a more detailed explanation on the migration, refer to [`saved objects management`](src/core/server/saved_objects/migrations/README.md). diff --git a/src/plugins/saved_objects/common/index.ts b/src/plugins/saved_objects/common/index.ts index 2d63a566b47..248fd2f508f 100644 --- a/src/plugins/saved_objects/common/index.ts +++ b/src/plugins/saved_objects/common/index.ts @@ -29,4 +29,7 @@ */ export const PER_PAGE_SETTING = 'savedObjects:perPage'; +export const PER_PAGE_VALUE = 20; + export const LISTING_LIMIT_SETTING = 'savedObjects:listingLimit'; +export const LISTING_LIMIT_VALUE = 1000; diff --git a/src/plugins/saved_objects/server/ui_settings.ts b/src/plugins/saved_objects/server/ui_settings.ts index bba4a7e61d8..32f2bda01ba 100644 --- a/src/plugins/saved_objects/server/ui_settings.ts +++ b/src/plugins/saved_objects/server/ui_settings.ts @@ -32,14 +32,19 @@ import { i18n } from '@osd/i18n'; import { schema } from '@osd/config-schema'; import { UiSettingsParams } from 'opensearch-dashboards/server'; -import { PER_PAGE_SETTING, LISTING_LIMIT_SETTING } from '../common'; +import { + PER_PAGE_SETTING, + PER_PAGE_VALUE, + LISTING_LIMIT_SETTING, + LISTING_LIMIT_VALUE, +} from '../common'; export const uiSettings: Record = { [PER_PAGE_SETTING]: { name: i18n.translate('savedObjects.advancedSettings.perPageTitle', { defaultMessage: 'Objects per page', }), - value: 20, + value: PER_PAGE_VALUE, type: 'number', description: i18n.translate('savedObjects.advancedSettings.perPageText', { defaultMessage: 'Number of objects to show per page in the load dialog', @@ -51,7 +56,7 @@ export const uiSettings: Record = { defaultMessage: 'Objects listing limit', }), type: 'number', - value: 1000, + value: LISTING_LIMIT_VALUE, description: i18n.translate('savedObjects.advancedSettings.listingLimitText', { defaultMessage: 'Number of objects to fetch for the listing pages', }), diff --git a/src/plugins/saved_objects_management/opensearch_dashboards.json b/src/plugins/saved_objects_management/opensearch_dashboards.json index 6d02893311e..f76b69999ec 100644 --- a/src/plugins/saved_objects_management/opensearch_dashboards.json +++ b/src/plugins/saved_objects_management/opensearch_dashboards.json @@ -3,8 +3,15 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["management", "data"], - "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "visBuilder"], + "requiredPlugins": ["management", "data", "uiActions"], + "optionalPlugins": [ + "dashboard", + "visualizations", + "discover", + "home", + "visBuilder", + "visAugmenter" + ], "extraPublicDirs": ["public/lib"], "requiredBundles": ["opensearchDashboardsReact", "home"] } diff --git a/src/plugins/saved_objects_management/public/index.ts b/src/plugins/saved_objects_management/public/index.ts index 2377afe175c..317b3079efa 100644 --- a/src/plugins/saved_objects_management/public/index.ts +++ b/src/plugins/saved_objects_management/public/index.ts @@ -48,6 +48,8 @@ export { } from './services'; export { ProcessedImportResponse, processImportResponse, FailedImport } from './lib'; export { SavedObjectRelation, SavedObjectWithMetadata, SavedObjectMetadata } from './types'; +export { SAVED_OBJECT_DELETE_TRIGGER, savedObjectDeleteTrigger } from './triggers'; +export { SavedObjectDeleteContext } from './ui_actions_bootstrap'; export function plugin(initializerContext: PluginInitializerContext) { return new SavedObjectsManagementPlugin(); diff --git a/src/plugins/saved_objects_management/public/lib/in_app_url.test.ts b/src/plugins/saved_objects_management/public/lib/in_app_url.test.ts index ab524bb5d99..f537bac4552 100644 --- a/src/plugins/saved_objects_management/public/lib/in_app_url.test.ts +++ b/src/plugins/saved_objects_management/public/lib/in_app_url.test.ts @@ -77,6 +77,22 @@ describe('canViewInApp', () => { expect(canViewInApp(uiCapabilities, 'visualizations')).toEqual(false); }); + it('should handle augment-vis', () => { + let uiCapabilities = createCapabilities({ + visAugmenter: { + show: true, + }, + }); + expect(canViewInApp(uiCapabilities, 'augment-vis')).toEqual(true); + + uiCapabilities = createCapabilities({ + visAugmenter: { + show: false, + }, + }); + expect(canViewInApp(uiCapabilities, 'augment-vis')).toEqual(false); + }); + it('should handle index patterns', () => { let uiCapabilities = createCapabilities({ management: { diff --git a/src/plugins/saved_objects_management/public/lib/in_app_url.ts b/src/plugins/saved_objects_management/public/lib/in_app_url.ts index e55eaa858f4..ea8a373bca8 100644 --- a/src/plugins/saved_objects_management/public/lib/in_app_url.ts +++ b/src/plugins/saved_objects_management/public/lib/in_app_url.ts @@ -38,6 +38,8 @@ export function canViewInApp(uiCapabilities: Capabilities, type: string): boolea case 'visualization': case 'visualizations': return uiCapabilities.visualize.show as boolean; + case 'augment-vis': + return uiCapabilities.visAugmenter.show as boolean; case 'index-pattern': case 'index-patterns': case 'indexPatterns': diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index 2c42df5c782..a1c7b5343eb 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -59,7 +59,7 @@ export const mountManagementSection = async ({ mountParams, serviceRegistry, }: MountParams) => { - const [coreStart, { data }, pluginStart] = await core.getStartServices(); + const [coreStart, { data, uiActions }, pluginStart] = await core.getStartServices(); const { element, history, setBreadcrumbs } = mountParams; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); @@ -88,6 +88,7 @@ export const mountManagementSection = async ({ }> | undefined) => { + return object?.attributes?.title ?? ''; + }; + redirectToListing() { this.props.history.push('/'); } diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap index 293b8c2e30d..a890c6977de 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap @@ -635,6 +635,140 @@ exports[`Relationships from legacy app should render visualizations normally 1`] `; +exports[`Relationships should render augment-vis objects normally 1`] = ` + + + +

+ MyAugmentVisObject +

+
+
+ +
+ +

+ Here are the saved objects related to MyAugmentVisObject. Deleting this augment-vis affects its parent objects, but not its children. +

+
+ + +
+
+
+`; + exports[`Relationships should render dashboards normally 1`] = ` { expect(component).toMatchSnapshot(); }); + it('should render augment-vis objects normally', async () => { + const props: RelationshipsProps = { + goInspectObject: () => {}, + canGoInApp: () => true, + basePath: httpServiceMock.createSetupContract().basePath, + getRelationships: jest.fn().mockImplementation(() => [ + { + type: 'visualization', + id: '1', + relationship: 'child', + meta: { + title: 'MyViz', + icon: 'visualizeApp', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', + inAppUrl: { + path: '/edit/1', + uiCapabilitiesPath: 'visualize.show', + }, + }, + }, + ]), + savedObject: { + id: '1', + type: 'augment-vis', + attributes: {}, + references: [], + meta: { + title: 'MyAugmentVisObject', + icon: 'savedObject', + editUrl: '/management/opensearch-dashboards/objects/savedAugmentVis/1', + }, + }, + close: jest.fn(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingSpinner').length).toBe(1); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); + it('should render dashboards normally', async () => { const props: RelationshipsProps = { goInspectObject: () => {}, diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_edition_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_edition_page.tsx index 51868e3e12c..49513e48427 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_edition_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_edition_page.tsx @@ -33,16 +33,19 @@ import { useParams, useLocation } from 'react-router-dom'; import { parse } from 'query-string'; import { i18n } from '@osd/i18n'; import { CoreStart, ChromeBreadcrumb, ScopedHistory } from 'src/core/public'; +import { UiActionsStart } from 'src/plugins/ui_actions/public'; import { ISavedObjectsManagementServiceRegistry } from '../services'; import { SavedObjectEdition } from './object_view'; const SavedObjectsEditionPage = ({ coreStart, + uiActionsStart, serviceRegistry, setBreadcrumbs, history, }: { coreStart: CoreStart; + uiActionsStart: UiActionsStart; serviceRegistry: ISavedObjectsManagementServiceRegistry; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; history: ScopedHistory; @@ -79,6 +82,7 @@ const SavedObjectsEditionPage = ({ savedObjectsClient={coreStart.savedObjects.client} overlays={coreStart.overlays} notifications={coreStart.notifications} + uiActions={uiActionsStart} capabilities={capabilities} notFoundType={query.notFound as string} history={history} diff --git a/src/plugins/saved_objects_management/public/plugin.test.ts b/src/plugins/saved_objects_management/public/plugin.test.ts index e55760846a5..c8e762f73dc 100644 --- a/src/plugins/saved_objects_management/public/plugin.test.ts +++ b/src/plugins/saved_objects_management/public/plugin.test.ts @@ -32,6 +32,7 @@ import { coreMock } from '../../../core/public/mocks'; import { homePluginMock } from '../../home/public/mocks'; import { managementPluginMock } from '../../management/public/mocks'; import { dataPluginMock } from '../../data/public/mocks'; +import { uiActionsPluginMock } from '../../ui_actions/public/mocks'; import { SavedObjectsManagementPlugin } from './plugin'; describe('SavedObjectsManagementPlugin', () => { @@ -48,8 +49,13 @@ describe('SavedObjectsManagementPlugin', () => { }); const homeSetup = homePluginMock.createSetupContract(); const managementSetup = managementPluginMock.createSetupContract(); + const uiActionsSetup = uiActionsPluginMock.createSetupContract(); - await plugin.setup(coreSetup, { home: homeSetup, management: managementSetup }); + await plugin.setup(coreSetup, { + home: homeSetup, + management: managementSetup, + uiActions: uiActionsSetup, + }); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledTimes(1); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledWith( diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index ec7d64ed700..43356eb8f9e 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -33,11 +33,13 @@ import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { VisBuilderStart } from '../../vis_builder/public'; import { ManagementSetup } from '../../management/public'; +import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public'; import { DataPublicPluginStart } from '../../data/public'; import { DashboardStart } from '../../dashboard/public'; import { DiscoverStart } from '../../discover/public'; import { HomePublicPluginSetup, FeatureCatalogueCategory } from '../../home/public'; import { VisualizationsStart } from '../../visualizations/public'; +import { VisAugmenterStart } from '../../vis_augmenter/public'; import { SavedObjectsManagementActionService, SavedObjectsManagementActionServiceSetup, @@ -52,6 +54,7 @@ import { ISavedObjectsManagementServiceRegistry, } from './services'; import { registerServices } from './register_services'; +import { bootstrap } from './ui_actions_bootstrap'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; @@ -69,14 +72,17 @@ export interface SavedObjectsManagementPluginStart { export interface SetupDependencies { management: ManagementSetup; home?: HomePublicPluginSetup; + uiActions: UiActionsSetup; } export interface StartDependencies { data: DataPublicPluginStart; dashboard?: DashboardStart; visualizations?: VisualizationsStart; + visAugmenter?: VisAugmenterStart; discover?: DiscoverStart; visBuilder?: VisBuilderStart; + uiActions: UiActionsStart; } export class SavedObjectsManagementPlugin @@ -94,7 +100,7 @@ export class SavedObjectsManagementPlugin public setup( core: CoreSetup, - { home, management }: SetupDependencies + { home, management, uiActions }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); const columnSetup = this.columnService.setup(); @@ -134,6 +140,9 @@ export class SavedObjectsManagementPlugin }, }); + // sets up the context mappings and registers any triggers/actions for the plugin + bootstrap(uiActions); + // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); @@ -145,7 +154,7 @@ export class SavedObjectsManagementPlugin }; } - public start(core: CoreStart, { data }: StartDependencies) { + public start(core: CoreStart, { data, uiActions }: StartDependencies) { const actionStart = this.actionService.start(); const columnStart = this.columnService.start(); const namespaceStart = this.namespaceService.start(); diff --git a/src/plugins/saved_objects_management/public/register_services.ts b/src/plugins/saved_objects_management/public/register_services.ts index 514ab66a459..1b11eb57854 100644 --- a/src/plugins/saved_objects_management/public/register_services.ts +++ b/src/plugins/saved_objects_management/public/register_services.ts @@ -36,7 +36,10 @@ export const registerServices = async ( registry: ISavedObjectsManagementServiceRegistry, getStartServices: StartServicesAccessor ) => { - const [, { dashboard, visualizations, discover, visBuilder }] = await getStartServices(); + const [ + , + { dashboard, visualizations, visAugmenter, discover, visBuilder }, + ] = await getStartServices(); if (dashboard) { registry.register({ @@ -54,6 +57,14 @@ export const registerServices = async ( }); } + if (visAugmenter) { + registry.register({ + id: 'savedAugmentVis', + title: 'augmentVis', + service: visAugmenter.savedAugmentVisLoader, + }); + } + if (discover) { registry.register({ id: 'savedSearches', diff --git a/src/plugins/saved_objects_management/public/triggers/index.ts b/src/plugins/saved_objects_management/public/triggers/index.ts new file mode 100644 index 00000000000..001864544cd --- /dev/null +++ b/src/plugins/saved_objects_management/public/triggers/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + SAVED_OBJECT_DELETE_TRIGGER, + savedObjectDeleteTrigger, +} from './saved_object_delete_trigger'; diff --git a/src/plugins/saved_objects_management/public/triggers/saved_object_delete_trigger.ts b/src/plugins/saved_objects_management/public/triggers/saved_object_delete_trigger.ts new file mode 100644 index 00000000000..c9104ec4176 --- /dev/null +++ b/src/plugins/saved_objects_management/public/triggers/saved_object_delete_trigger.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Trigger } from '../../../ui_actions/public'; + +/** + * TODO: This action is currently being used behind-the-scenes in the vis_augmenter plugin + * to clean up related augment-vis saved objects when a visualization is deleted. + * This should be moved and maintained by the saved objects plugin. Tracking issue: + * https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4499 + */ +export const SAVED_OBJECT_DELETE_TRIGGER = 'SAVED_OBJECT_DELETE_TRIGGER'; +export const savedObjectDeleteTrigger: Trigger<'SAVED_OBJECT_DELETE_TRIGGER'> = { + id: SAVED_OBJECT_DELETE_TRIGGER, + title: i18n.translate('savedObjectsManagement.triggers.savedObjectDeleteTitle', { + defaultMessage: 'Saved object delete', + }), + description: i18n.translate('savedObjectsManagement.triggers.savedObjectDeleteDescription', { + defaultMessage: 'Perform additional actions after deleting a saved object', + }), +}; diff --git a/src/plugins/saved_objects_management/public/ui_actions_bootstrap.ts b/src/plugins/saved_objects_management/public/ui_actions_bootstrap.ts new file mode 100644 index 00000000000..e005ba660de --- /dev/null +++ b/src/plugins/saved_objects_management/public/ui_actions_bootstrap.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { UiActionsSetup } from '../../ui_actions/public'; +import { SAVED_OBJECT_DELETE_TRIGGER, savedObjectDeleteTrigger } from './triggers'; + +export interface SavedObjectDeleteContext { + type: string; + savedObjectId: string; +} + +declare module '../../ui_actions/public' { + export interface TriggerContextMapping { + [SAVED_OBJECT_DELETE_TRIGGER]: SavedObjectDeleteContext; + } +} + +export const bootstrap = (uiActions: UiActionsSetup) => { + uiActions.registerTrigger(savedObjectDeleteTrigger); +}; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index f8dc8b7b8ae..c4ff960b62e 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -1337,6 +1337,12 @@ "visualization:tileMap:maxPrecision": { "type": "long" }, + "visualization:enablePluginAugmentation": { + "type": "boolean" + }, + "visualization:enablePluginAugmentation.maxPluginObjects": { + "type": "number" + }, "securitySolution:ipReputationLinks": { "type": "text" }, diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 3560f473d33..489cb5eee36 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -61,6 +61,8 @@ export { visualizeFieldTrigger, VISUALIZE_GEO_FIELD_TRIGGER, visualizeGeoFieldTrigger, + EXTERNAL_ACTION_TRIGGER, + externalActionTrigger, } from './triggers'; export { TriggerContextMapping, diff --git a/src/plugins/ui_actions/public/triggers/external_action_trigger.ts b/src/plugins/ui_actions/public/triggers/external_action_trigger.ts new file mode 100644 index 00000000000..37af88f9b54 --- /dev/null +++ b/src/plugins/ui_actions/public/triggers/external_action_trigger.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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.', + }), +}; diff --git a/src/plugins/ui_actions/public/triggers/index.ts b/src/plugins/ui_actions/public/triggers/index.ts index 2d012df76e0..0fcbbc4ee3f 100644 --- a/src/plugins/ui_actions/public/triggers/index.ts +++ b/src/plugins/ui_actions/public/triggers/index.ts @@ -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'; diff --git a/src/plugins/vis_augmenter/README.md b/src/plugins/vis_augmenter/README.md new file mode 100644 index 00000000000..4ebe2e4b1d4 --- /dev/null +++ b/src/plugins/vis_augmenter/README.md @@ -0,0 +1 @@ +Contains interfaces, type definitions, helper functions, and services used for allowing external plugins to augment Visualizations. Registers the relevant saved object types and expression functions. diff --git a/src/plugins/vis_augmenter/common/augment_vis_saved_object_attributes.ts b/src/plugins/vis_augmenter/common/augment_vis_saved_object_attributes.ts new file mode 100644 index 00000000000..824f35c6e11 --- /dev/null +++ b/src/plugins/vis_augmenter/common/augment_vis_saved_object_attributes.ts @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectAttributes } from 'opensearch-dashboards/server'; + +export interface AugmentVisSavedObjectAttributes extends SavedObjectAttributes { + id: string; + title: string; + description?: string; + originPlugin: string; + pluginResource: { + type: string; + id: string; + }; + visLayerExpressionFn: { + type: string; + name: string; + }; + version: number; + // Following fields are optional since they will get set/removed during the extraction/injection + // of the vis reference + visName?: string; + visId?: string; + visReference?: { + id: string; + name: string; + }; + // Error may be populated if there is some issue when parsing the attribute values + error?: string; +} diff --git a/src/plugins/vis_augmenter/common/constants.ts b/src/plugins/vis_augmenter/common/constants.ts new file mode 100644 index 00000000000..438e2b9c5ba --- /dev/null +++ b/src/plugins/vis_augmenter/common/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const APP_PATH = { + STATS: '/stats', +}; +export const APP_API = '/api/vis_augmenter'; + +export const PLUGIN_AUGMENTATION_ENABLE_SETTING = 'visualization:enablePluginAugmentation'; +export const PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING = + 'visualization:enablePluginAugmentation.maxPluginObjects'; diff --git a/src/plugins/vis_augmenter/common/index.ts b/src/plugins/vis_augmenter/common/index.ts new file mode 100644 index 00000000000..9762ce68770 --- /dev/null +++ b/src/plugins/vis_augmenter/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './constants'; +export { AugmentVisSavedObjectAttributes } from './augment_vis_saved_object_attributes'; diff --git a/src/plugins/vis_augmenter/config.ts b/src/plugins/vis_augmenter/config.ts new file mode 100644 index 00000000000..12b9854f451 --- /dev/null +++ b/src/plugins/vis_augmenter/config.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +export const configSchema = schema.object({ + pluginAugmentationEnabled: schema.boolean({ defaultValue: true }), +}); + +export type VisAugmenterPluginConfigType = TypeOf; diff --git a/src/plugins/vis_augmenter/opensearch_dashboards.json b/src/plugins/vis_augmenter/opensearch_dashboards.json new file mode 100644 index 00000000000..9026bdd2485 --- /dev/null +++ b/src/plugins/vis_augmenter/opensearch_dashboards.json @@ -0,0 +1,16 @@ +{ + "id": "visAugmenter", + "version": "opensearchDashboards", + "server": true, + "ui": true, + "requiredPlugins": [ + "data", + "savedObjects", + "opensearchDashboardsUtils", + "expressions", + "visualizations", + "uiActions", + "embeddable" + ], + "requiredBundles": ["opensearchDashboardsReact", "savedObjectsManagement"] +} diff --git a/src/plugins/vis_augmenter/public/actions/index.ts b/src/plugins/vis_augmenter/public/actions/index.ts new file mode 100644 index 00000000000..893032f8681 --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + PLUGIN_RESOURCE_DELETE_ACTION, + PluginResourceDeleteAction, +} from './plugin_resource_delete_action'; +export { SAVED_OBJECT_DELETE_ACTION, SavedObjectDeleteAction } from './saved_object_delete_action'; diff --git a/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.test.ts b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.test.ts new file mode 100644 index 00000000000..907b383c60a --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createPointInTimeEventsVisLayer } from '../mocks'; +import { generateAugmentVisSavedObject } from '../saved_augment_vis'; +import { PluginResourceDeleteAction } from './plugin_resource_delete_action'; + +jest.mock('src/plugins/vis_augmenter/public/services.ts', () => { + return { + getSavedAugmentVisLoader: () => { + return { + delete: () => {}, + findAll: () => { + return { + hits: [], + }; + }, + }; + }, + }; +}); + +const sampleSavedObj = generateAugmentVisSavedObject( + 'test-id', + { + type: 'PointInTimeEvents', + name: 'test-fn-name', + args: {}, + }, + 'test-vis-id', + 'test-origin-plugin', + { + type: 'test-resource-type', + id: 'test-resource-id', + } +); + +const sampleVisLayer = createPointInTimeEventsVisLayer(); + +describe('SavedObjectDeleteAction', () => { + it('is incompatible with invalid saved obj list', async () => { + const action = new PluginResourceDeleteAction(); + const visLayers = [sampleVisLayer]; + // @ts-ignore + expect(await action.isCompatible({ savedObjs: null, visLayers })).toBe(false); + // @ts-ignore + expect(await action.isCompatible({ savedObjs: undefined, visLayers })).toBe(false); + expect(await action.isCompatible({ savedObjs: [], visLayers })).toBe(false); + }); + + it('is incompatible with invalid vislayer list', async () => { + const action = new PluginResourceDeleteAction(); + const savedObjs = [sampleSavedObj]; + // @ts-ignore + expect(await action.isCompatible({ savedObjs, visLayers: null })).toBe(false); + // @ts-ignore + expect(await action.isCompatible({ savedObjs, visLayers: undefined })).toBe(false); + expect(await action.isCompatible({ savedObjs, visLayers: [] })).toBe(false); + }); + + it('execute throws error if incompatible saved objs list', async () => { + const action = new PluginResourceDeleteAction(); + async function check(savedObjs: any, visLayers: any) { + await action.execute({ savedObjs, visLayers }); + } + await expect(check(null, [sampleVisLayer])).rejects.toThrow(Error); + }); + + it('execute throws error if incompatible vis layer list', async () => { + const action = new PluginResourceDeleteAction(); + async function check(savedObjs: any, visLayers: any) { + await action.execute({ savedObjs, visLayers }); + } + await expect(check([sampleSavedObj], null)).rejects.toThrow(Error); + }); + + it('execute is successful if valid saved obj and vis layer lists', async () => { + const action = new PluginResourceDeleteAction(); + async function check(savedObjs: any, visLayers: any) { + await action.execute({ savedObjs, visLayers }); + } + await expect(check([sampleSavedObj], [sampleVisLayer])).resolves; + }); + + it('Returns display name', async () => { + const action = new PluginResourceDeleteAction(); + expect(action.getDisplayName()).toBeDefined(); + }); + + it('Returns icon type', async () => { + const action = new PluginResourceDeleteAction(); + expect(action.getIconType()).toBeDefined(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts new file mode 100644 index 00000000000..6e3939820d2 --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isEmpty } from 'lodash'; +import { i18n } from '@osd/i18n'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { Action, IncompatibleActionError } from '../../../ui_actions/public'; +import { getSavedAugmentVisLoader } from '../services'; +import { PluginResourceDeleteContext } from '../ui_actions_bootstrap'; +import { cleanupStaleObjects } from '../utils'; + +export const PLUGIN_RESOURCE_DELETE_ACTION = 'PLUGIN_RESOURCE_DELETE_ACTION'; + +export class PluginResourceDeleteAction implements Action { + public readonly type = PLUGIN_RESOURCE_DELETE_ACTION; + public readonly id = PLUGIN_RESOURCE_DELETE_ACTION; + public order = 1; + + public getIconType(): EuiIconType { + return `trash`; + } + + public getDisplayName() { + return i18n.translate('dashboard.actions.deleteSavedObject.name', { + defaultMessage: + 'Clean up all augment-vis saved objects associated to the deleted visualization', + }); + } + + public async isCompatible({ savedObjs, visLayers }: PluginResourceDeleteContext) { + return !isEmpty(savedObjs) && !isEmpty(visLayers); + } + + /** + * If we have just collected all of the saved objects and generated vis layers, + * sweep through them all and if any of the resources are deleted, delete those + * corresponding saved objects + */ + public async execute({ savedObjs, visLayers }: PluginResourceDeleteContext) { + if (!(await this.isCompatible({ savedObjs, visLayers }))) { + throw new IncompatibleActionError(); + } + cleanupStaleObjects(savedObjs, visLayers, getSavedAugmentVisLoader()); + } +} diff --git a/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.test.ts b/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.test.ts new file mode 100644 index 00000000000..cec742a218c --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectDeleteAction } from './saved_object_delete_action'; +import services from '../services'; + +jest.mock('src/plugins/vis_augmenter/public/services.ts', () => { + return { + getSavedAugmentVisLoader: () => { + return { + delete: () => {}, + findAll: () => { + return { + hits: [], + }; + }, + }; + }, + }; +}); + +describe('SavedObjectDeleteAction', () => { + it('is incompatible with invalid types', async () => { + const action = new SavedObjectDeleteAction(); + const savedObjectId = '1234'; + // @ts-ignore + expect(await action.isCompatible({ type: null, savedObjectId })).toBe(false); + // @ts-ignore + expect(await action.isCompatible({ type: undefined, savedObjectId })).toBe(false); + expect(await action.isCompatible({ type: '', savedObjectId })).toBe(false); + expect(await action.isCompatible({ type: 'not-visualization-type', savedObjectId })).toBe( + false + ); + expect(await action.isCompatible({ type: 'savedSearch', savedObjectId })).toBe(false); + }); + + it('is incompatible with invalid saved obj ids', async () => { + const action = new SavedObjectDeleteAction(); + const type = 'visualization'; + // @ts-ignore + expect(await action.isCompatible({ type, savedObjectId: null })).toBe(false); + // @ts-ignore + expect(await action.isCompatible({ type, savedObjectId: undefined })).toBe(false); + expect(await action.isCompatible({ type, savedObjectId: '' })).toBe(false); + }); + + it('execute throws error if incompatible type', async () => { + const action = new SavedObjectDeleteAction(); + async function check(type: any, id: any) { + await action.execute({ type, savedObjectId: id }); + } + await expect(check(null, '1234')).rejects.toThrow(Error); + }); + + it('execute throws error if incompatible saved obj id', async () => { + const action = new SavedObjectDeleteAction(); + async function check(type: any, id: any) { + await action.execute({ type, savedObjectId: id }); + } + await expect(check('visualization', null)).rejects.toThrow(Error); + }); + + it('execute is successful if valid type and saved obj id', async () => { + const getLoaderSpy = jest.spyOn(services, 'getSavedAugmentVisLoader'); + const action = new SavedObjectDeleteAction(); + async function check(type: any, id: any) { + await action.execute({ type, savedObjectId: id }); + } + await expect(check('visualization', 'test-id')).resolves; + expect(getLoaderSpy).toHaveBeenCalledTimes(1); + }); + + it('Returns display name', async () => { + const action = new SavedObjectDeleteAction(); + expect(action.getDisplayName()).toBeDefined(); + }); + + it('Returns icon type', async () => { + const action = new SavedObjectDeleteAction(); + expect(action.getIconType()).toBeDefined(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.ts b/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.ts new file mode 100644 index 00000000000..56194e28154 --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.ts @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isEmpty } from 'lodash'; +import { i18n } from '@osd/i18n'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { Action, IncompatibleActionError } from '../../../ui_actions/public'; +import { getAllAugmentVisSavedObjs } from '../utils'; +import { getSavedAugmentVisLoader } from '../services'; +import { SavedObjectDeleteContext } from '../ui_actions_bootstrap'; + +export const SAVED_OBJECT_DELETE_ACTION = 'SAVED_OBJECT_DELETE_ACTION'; + +export class SavedObjectDeleteAction implements Action { + public readonly type = SAVED_OBJECT_DELETE_ACTION; + public readonly id = SAVED_OBJECT_DELETE_ACTION; + public order = 1; + + public getIconType(): EuiIconType { + return `trash`; + } + + public getDisplayName() { + return i18n.translate('dashboard.actions.deleteSavedObject.name', { + defaultMessage: 'Clean up augment-vis saved objects associated to a deleted vis', + }); + } + + public async isCompatible({ type, savedObjectId }: SavedObjectDeleteContext) { + return type === 'visualization' && !!savedObjectId; + } + + /** + * If deletion of a vis saved object has been triggered, we want to clean up + * any augment-vis saved objects that have a reference to this vis since it + * is now stale. + * TODO: this should be automatically handled by the saved objects plugin, instead + * of this specific scenario in the vis_augmenter plugin. Tracking issue: + * https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4499 + */ + public async execute({ type, savedObjectId }: SavedObjectDeleteContext) { + if (!(await this.isCompatible({ type, savedObjectId }))) { + throw new IncompatibleActionError(); + } + + const loader = getSavedAugmentVisLoader(); + const allAugmentVisObjs = await getAllAugmentVisSavedObjs(loader); + const augmentVisIdsToDelete = allAugmentVisObjs + .filter((augmentVisObj) => augmentVisObj.visId === savedObjectId) + .map((augmentVisObj) => augmentVisObj.id as string); + + if (!isEmpty(augmentVisIdsToDelete)) loader.delete(augmentVisIdsToDelete); + } +} diff --git a/src/plugins/vis_augmenter/public/constants.ts b/src/plugins/vis_augmenter/public/constants.ts new file mode 100644 index 00000000000..9d422cf743a --- /dev/null +++ b/src/plugins/vis_augmenter/public/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const VIS_LAYER_COLUMN_TYPE = 'vis_layer'; +export const EVENT_COLOR = '#D36086'; // This is the value of ouiColorVis2 - we need the raw value so that vega can understand it +export const HOVER_PARAM = 'HOVER'; +export const EVENT_MARK_SIZE = 40; +export const EVENT_MARK_SIZE_ENLARGED = 80; +export const EVENT_MARK_SHAPE = 'triangle-up'; +export const EVENT_TIMELINE_HEIGHT = 25; +export const EVENT_TOOLTIP_CENTER_ON_MARK = 5; diff --git a/src/plugins/vis_augmenter/public/expressions/index.ts b/src/plugins/vis_augmenter/public/expressions/index.ts new file mode 100644 index 00000000000..9f269633f30 --- /dev/null +++ b/src/plugins/vis_augmenter/public/expressions/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './types'; diff --git a/src/plugins/vis_augmenter/public/expressions/types.ts b/src/plugins/vis_augmenter/public/expressions/types.ts new file mode 100644 index 00000000000..b907e570e10 --- /dev/null +++ b/src/plugins/vis_augmenter/public/expressions/types.ts @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExpressionTypeDefinition, ExpressionFunctionDefinition } from '../../../expressions'; +import { VisLayers, VisLayerTypes } from '../'; + +const name = 'vis_layers'; + +export interface ExprVisLayers { + type: typeof name; + layers: VisLayers; +} + +// Setting default empty arrays for null & undefined edge cases +export const visLayers: ExpressionTypeDefinition = { + name, + from: { + null: () => { + return { + type: name, + layers: [] as VisLayers, + } as ExprVisLayers; + }, + undefined: () => { + return { + type: name, + layers: [] as VisLayers, + } as ExprVisLayers; + }, + }, +}; + +export type VisLayerFunctionDefinition = ExpressionFunctionDefinition< + string, + ExprVisLayers, + any, + Promise +>; + +export interface VisLayerExpressionFn { + type: keyof typeof VisLayerTypes; + name: string; + // plugin expression fns can freely set custom arguments + args: { [key: string]: any }; +} diff --git a/src/plugins/vis_augmenter/public/index.ts b/src/plugins/vis_augmenter/public/index.ts new file mode 100644 index 00000000000..d5096b6faf1 --- /dev/null +++ b/src/plugins/vis_augmenter/public/index.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { VisAugmenterPlugin, VisAugmenterSetup, VisAugmenterStart } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new VisAugmenterPlugin(initializerContext); +} +export { VisAugmenterSetup, VisAugmenterStart }; + +export { + VisLayer, + VisLayers, + VisLayerTypes, + VisLayerErrorTypes, + VisLayerError, + PluginResource, + PointInTimeEvent, + PointInTimeEventsVisLayer, + isPointInTimeEventsVisLayer, + isVisLayerWithError, + VisAugmenterEmbeddableConfig, + VisFlyoutContext, +} from './types'; + +export { AugmentVisContext } from './ui_actions_bootstrap'; + +export * from './expressions'; +export * from './utils'; +export * from './constants'; +export * from './vega'; +export * from './saved_augment_vis'; +export * from './test_constants'; +export * from './triggers'; +export * from './actions'; +export { fetchVisEmbeddable } from './view_events_flyout'; +export { setUISettings } from './services'; // Needed for plugin tests related to the CRUD saved object functions diff --git a/src/plugins/vis_augmenter/public/mocks.ts b/src/plugins/vis_augmenter/public/mocks.ts new file mode 100644 index 00000000000..33a6c3868e6 --- /dev/null +++ b/src/plugins/vis_augmenter/public/mocks.ts @@ -0,0 +1,228 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get } from 'lodash'; +import { VisualizeEmbeddable, Vis } from '../../visualizations/public'; +import { ErrorEmbeddable } from '../../embeddable/public'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { timefilterServiceMock } from '../../data/public/query/timefilter/timefilter_service.mock'; +import { EventVisEmbeddableItem } from './view_events_flyout'; +import { + VisLayer, + VisLayerTypes, + VisLayerErrorTypes, + PointInTimeEventsVisLayer, + PluginResource, + PointInTimeEvent, +} from './types'; +import { AggConfigs, AggTypesRegistryStart, IndexPattern } from '../../data/common'; +import { mockAggTypesRegistry } from '../../data/common/search/aggs/test_helpers'; + +export const VALID_CONFIG_STATES = [ + { + enabled: true, + type: 'max', + params: {}, + schema: 'metric', + }, + { + enabled: true, + type: 'date_histogram', + params: {}, + schema: 'segment', + }, +]; + +export const STUB_INDEX_PATTERN_WITH_FIELDS = { + id: '1234', + title: 'logstash-*', + fields: [ + { + name: 'response', + type: 'number', + esTypes: ['integer'], + aggregatable: true, + filterable: true, + searchable: true, + }, + ], +} as IndexPattern; + +export const TYPES_REGISTRY: AggTypesRegistryStart = mockAggTypesRegistry(); + +export const VALID_AGGS = new AggConfigs(STUB_INDEX_PATTERN_WITH_FIELDS, VALID_CONFIG_STATES, { + typesRegistry: TYPES_REGISTRY, +}); + +export const VALID_VIS = ({ + type: {}, + uiState: { + on: jest.fn(), + }, + params: { + type: 'line', + seriesParams: [ + { + type: 'line', + }, + ], + categoryAxes: [ + { + position: 'bottom', + }, + ], + }, + data: { + aggs: VALID_AGGS, + }, +} as unknown) as Vis; + +const SAVED_OBJ_ID = 'test-saved-obj-id'; +const VIS_TITLE = 'test-vis-title'; +const ORIGIN_PLUGIN = 'test-plugin'; +const PLUGIN_RESOURCE = { + type: 'test-type', + id: 'test-resource-id', + name: 'test-resource-name', + urlPath: 'test-url-path', +} as PluginResource; +const EVENT_COUNT = 3; +const ERROR_MESSAGE = 'test-error-message'; +const EVENT_TYPE = 'test-event-type'; + +export const createPluginResource = ( + type: string = PLUGIN_RESOURCE.type, + id: string = PLUGIN_RESOURCE.id, + name: string = PLUGIN_RESOURCE.name, + urlPath: string = PLUGIN_RESOURCE.urlPath +): PluginResource => { + 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, + validVis: boolean = true +): VisualizeEmbeddable => { + const mockTimeFilterService = timefilterServiceMock.createStartContract(); + const mockTimeFilter = mockTimeFilterService.timefilter; + const mockVis = validVis + ? VALID_VIS + : (({ + type: {}, + data: {}, + uiState: { + on: jest.fn(), + }, + params: { + type: 'line', + seriesParams: [], + }, + } 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; + mockVisEmbeddable.visLayers = [createPointInTimeEventsVisLayer()]; + return mockVisEmbeddable; +}; + +export const createPointInTimeEventsVisLayer = ( + originPlugin: string = ORIGIN_PLUGIN, + pluginResource: PluginResource = PLUGIN_RESOURCE, + eventCount: number = EVENT_COUNT, + error: boolean = false, + errorMessage: string = ERROR_MESSAGE +): 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, + pluginEventType: EVENT_TYPE, + error: error + ? { + type: VisLayerErrorTypes.FETCH_FAILURE, + message: errorMessage, + } + : undefined, + }; +}; + +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, + }; +}; + +export const createVisLayer = ( + type: any, + error: boolean = false, + errorMessage: string = 'some-error-message', + resource?: { + type?: string; + id?: string; + name?: string; + urlPath?: string; + } +): VisLayer => { + return { + type, + originPlugin: 'test-plugin', + pluginResource: { + type: get(resource, 'type', 'test-resource-type'), + id: get(resource, 'id', 'test-resource-id'), + name: get(resource, 'name', 'test-resource-name'), + urlPath: get(resource, 'urlPath', 'test-resource-url-path'), + }, + error: error + ? { + type: VisLayerErrorTypes.FETCH_FAILURE, + message: errorMessage, + } + : undefined, + }; +}; diff --git a/src/plugins/vis_augmenter/public/plugin.ts b/src/plugins/vis_augmenter/public/plugin.ts new file mode 100644 index 00000000000..9760bfd75b2 --- /dev/null +++ b/src/plugins/vis_augmenter/public/plugin.ts @@ -0,0 +1,85 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExpressionsSetup } from '../../expressions/public'; +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { visLayers } from './expressions'; +import { setSavedAugmentVisLoader, setUISettings } from './services'; +import { createSavedAugmentVisLoader, SavedAugmentVisLoader } from './saved_augment_vis'; +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'; +import { VIEW_EVENTS_FLYOUT_STATE, setFlyoutState } from './view_events_flyout'; +import { bootstrapUiActions } from './ui_actions_bootstrap'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisAugmenterSetup {} + +export interface VisAugmenterStart { + savedAugmentVisLoader: SavedAugmentVisLoader; +} + +export interface VisAugmenterSetupDeps { + expressions: ExpressionsSetup; +} + +export interface VisAugmenterStartDeps { + uiActions: UiActionsStart; + embeddable: EmbeddableStart; + data: DataPublicPluginStart; + visualizations: VisualizationsStart; +} + +export class VisAugmenterPlugin + implements + Plugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup( + core: CoreSetup, + { expressions }: VisAugmenterSetupDeps + ): VisAugmenterSetup { + expressions.registerType(visLayers); + setUISettings(core.uiSettings); + + return {}; + } + + public start( + core: CoreStart, + { uiActions, embeddable, data, visualizations }: VisAugmenterStartDeps + ): VisAugmenterStart { + setUiActions(uiActions); + setEmbeddable(embeddable); + setQueryService(data.query); + setVisualizations(visualizations); + setCore(core); + setFlyoutState(VIEW_EVENTS_FLYOUT_STATE.CLOSED); + + const savedAugmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + }); + setSavedAugmentVisLoader(savedAugmentVisLoader); + + // sets up the context mappings and registers any triggers/actions for the plugin + bootstrapUiActions(uiActions); + + return { savedAugmentVisLoader }; + } + + public stop() {} +} diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/_saved_augment_vis.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/_saved_augment_vis.ts new file mode 100644 index 00000000000..d40382b3a10 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/_saved_augment_vis.ts @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @name SavedAugmentVis + * + * @extends SavedObject. + */ +import { get } from 'lodash'; +import { + createSavedObjectClass, + SavedObject, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; +import { extractReferences, injectReferences } from './saved_augment_vis_references'; +import { AugmentVisSavedObjectAttributes } from '../../common'; + +const name = 'augment-vis'; + +export function createSavedAugmentVisClass(services: SavedObjectOpenSearchDashboardsServices) { + const SavedObjectClass = createSavedObjectClass(services); + + class SavedAugmentVis extends SavedObjectClass { + public static type: string = name; + public static mapping: Record = { + title: 'text', + description: 'text', + originPlugin: 'text', + pluginResource: 'object', + visLayerExpressionFn: 'object', + visId: 'keyword,', + version: 'integer', + }; + + constructor(opts: Record | string = {}) { + // The default delete() fn from the saved object loader will only + // pass a string ID. To handle that case here, we embed it within + // an opts object. + if (typeof opts !== 'object') { + opts = { id: opts }; + } + super({ + type: SavedAugmentVis.type, + mapping: SavedAugmentVis.mapping, + extractReferences, + injectReferences, + id: (opts.id as string) || '', + defaults: { + description: get(opts, 'description', ''), + originPlugin: get(opts, 'originPlugin', ''), + pluginResource: get(opts, 'pluginResource', {}), + visId: get(opts, 'visId', ''), + visLayerExpressionFn: get(opts, 'visLayerExpressionFn', {}), + version: 1, + }, + }); + this.showInRecentlyAccessed = false; + } + } + + return SavedAugmentVis as new (opts: AugmentVisSavedObjectAttributes) => SavedObject; +} diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/index.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/index.ts new file mode 100644 index 00000000000..ce168020495 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './saved_augment_vis'; +export * from './utils'; +export * from './types'; diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.test.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.test.ts new file mode 100644 index 00000000000..99975a54039 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.test.ts @@ -0,0 +1,252 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisLayerTypes } from '../types'; +import { VisLayerExpressionFn } from '../expressions'; +import { + createSavedAugmentVisLoader, + SavedObjectOpenSearchDashboardsServicesWithAugmentVis, +} from './saved_augment_vis'; +import { generateAugmentVisSavedObject, getMockAugmentVisSavedObjectClient } from './utils'; +import { uiSettingsServiceMock } from '../../../../core/public/mocks'; +import { setUISettings } from '../services'; +import { PLUGIN_AUGMENTATION_ENABLE_SETTING } from '../../common/constants'; +import { ISavedPluginResource } from './types'; + +const uiSettingsMock = uiSettingsServiceMock.createStartContract(); +setUISettings(uiSettingsMock); + +describe('SavedObjectLoaderAugmentVis', () => { + uiSettingsMock.get.mockImplementation((key: string) => { + return key === PLUGIN_AUGMENTATION_ENABLE_SETTING; + }); + + const fn = { + type: VisLayerTypes.PointInTimeEvents, + name: 'test-fn', + args: { + testArg: 'test-value', + }, + } as VisLayerExpressionFn; + const originPlugin = 'test-plugin'; + const pluginResource = { + type: 'test-plugin', + id: 'test-plugin-resource-id', + }; + const validObj1 = generateAugmentVisSavedObject( + 'valid-obj-id-1', + fn, + 'test-vis-id', + originPlugin, + pluginResource + ); + const validObj2 = generateAugmentVisSavedObject( + 'valid-obj-id-2', + fn, + 'test-vis-id', + originPlugin, + pluginResource + ); + const invalidFnTypeObj = generateAugmentVisSavedObject( + 'invalid-fn-obj-id-1', + { + ...fn, + // @ts-ignore + type: 'invalid-type', + }, + 'test-vis-id', + originPlugin, + pluginResource + ); + + const missingFnObj = generateAugmentVisSavedObject( + 'missing-fn-obj-id-1', + {} as VisLayerExpressionFn, + 'test-vis-id', + originPlugin, + pluginResource + ); + + const missingOriginPluginObj = generateAugmentVisSavedObject( + 'missing-origin-plugin-obj-id-1', + fn, + 'test-vis-id', + // @ts-ignore + undefined, + pluginResource + ); + + const missingPluginResourceTypeObj = generateAugmentVisSavedObject( + 'missing-plugin-resource-type-obj-id-1', + fn, + 'test-vis-id', + // @ts-ignore + originPlugin, + { + id: pluginResource.id, + } as ISavedPluginResource + ); + + const missingPluginResourceIdObj = generateAugmentVisSavedObject( + 'missing-plugin-resource-id-obj-id-1', + fn, + 'test-vis-id', + // @ts-ignore + originPlugin, + { + type: pluginResource.type, + } as ISavedPluginResource + ); + + it('find returns single saved obj', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([validObj1]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.find(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('valid-obj-id-1'); + expect(resp.hits[0].error).toEqual(undefined); + }); + + it('find returns multiple saved objs', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([validObj1, validObj2]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.find(); + expect(resp.hits.length).toEqual(2); + expect(resp.hits[0].id).toEqual('valid-obj-id-1'); + expect(resp.hits[1].id).toEqual('valid-obj-id-2'); + expect(resp.hits[0].error).toEqual(undefined); + expect(resp.hits[1].error).toEqual(undefined); + }); + + it('find returns empty response', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.find(); + expect(resp.hits.length).toEqual(0); + }); + + it('find does not return objs with errors', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([invalidFnTypeObj]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.find(); + expect(resp.hits.length).toEqual(0); + }); + + it('findAll returns obj with invalid VisLayer fn', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([invalidFnTypeObj]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.findAll(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('invalid-fn-obj-id-1'); + expect(resp.hits[0].error).toEqual('Unknown VisLayer expression function type'); + }); + + it('findAll returns obj with missing VisLayer fn', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([missingFnObj]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.findAll(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('missing-fn-obj-id-1'); + expect(resp.hits[0].error).toEqual( + 'visLayerExpressionFn is missing in augment-vis saved object' + ); + }); + + it('findAll returns obj with missing reference', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([validObj1], false), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.findAll(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('valid-obj-id-1'); + expect(resp.hits[0].error).toEqual('visReference is missing in augment-vis saved object'); + }); + + it('findAll returns obj with missing originPlugin', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([missingOriginPluginObj]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.findAll(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('missing-origin-plugin-obj-id-1'); + expect(resp.hits[0].error).toEqual('originPlugin is missing in augment-vis saved object'); + }); + + it('findAll returns obj with missing plugin resource type', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([missingPluginResourceTypeObj]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.findAll(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('missing-plugin-resource-type-obj-id-1'); + expect(resp.hits[0].error).toEqual( + 'pluginResource.type is missing in augment-vis saved object' + ); + }); + + it('findAll returns obj with missing plugin resource id', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([missingPluginResourceIdObj]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.findAll(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('missing-plugin-resource-id-obj-id-1'); + expect(resp.hits[0].error).toEqual('pluginResource.id is missing in augment-vis saved object'); + }); + + it('find returns exception due to setting being disabled', async () => { + uiSettingsMock.get.mockImplementation((key: string) => { + return key !== PLUGIN_AUGMENTATION_ENABLE_SETTING; + }); + const loader = createSavedAugmentVisLoader(({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([]), + } as unknown) as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + try { + await loader.find(); + } catch (e) { + expect(e.message).toBe( + 'Visualization augmentation is disabled, please enable visualization:enablePluginAugmentation.' + ); + } + }); + + it('findAll returns exception due to setting being disabled', async () => { + uiSettingsMock.get.mockImplementation((key: string) => { + return key !== PLUGIN_AUGMENTATION_ENABLE_SETTING; + }); + const loader = createSavedAugmentVisLoader(({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([]), + } as unknown) as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + try { + await loader.findAll(); + } catch (e) { + expect(e.message).toBe( + 'Visualization augmentation is disabled, please enable visualization:enablePluginAugmentation.' + ); + } + }); + + it('get returns exception due to setting being disabled', async () => { + uiSettingsMock.get.mockImplementation((key: string) => { + return key !== PLUGIN_AUGMENTATION_ENABLE_SETTING; + }); + const loader = createSavedAugmentVisLoader(({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([]), + } as unknown) as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + try { + await loader.get(); + } catch (e) { + expect(e.message).toBe( + 'Visualization augmentation is disabled, please enable visualization:enablePluginAugmentation.' + ); + } + }); +}); diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.ts new file mode 100644 index 00000000000..28b7831d943 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.ts @@ -0,0 +1,121 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get, isEmpty } from 'lodash'; +import { IUiSettingsClient } from 'opensearch-dashboards/public'; +import { + SavedObjectLoader, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; +import { createSavedAugmentVisClass } from './_saved_augment_vis'; +import { VisLayerTypes } from '../types'; +import { getUISettings } from '../services'; +import { AugmentVisSavedObjectAttributes, PLUGIN_AUGMENTATION_ENABLE_SETTING } from '../../common'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SavedObjectOpenSearchDashboardsServicesWithAugmentVis + extends SavedObjectOpenSearchDashboardsServices {} +export type SavedAugmentVisLoader = ReturnType; + +export class SavedObjectLoaderAugmentVis extends SavedObjectLoader { + private readonly config: IUiSettingsClient = getUISettings(); + + mapHitSource = (source: AugmentVisSavedObjectAttributes, id: string) => { + source.id = id; + source.visId = get(source, 'visReference.id', '') as string; + + if (isEmpty(source.visReference)) { + source.error = 'visReference is missing in augment-vis saved object'; + return source; + } + if (isEmpty(source.visLayerExpressionFn)) { + source.error = 'visLayerExpressionFn is missing in augment-vis saved object'; + return source; + } + if (!((get(source, 'visLayerExpressionFn.type', '') as string) in VisLayerTypes)) { + source.error = 'Unknown VisLayer expression function type'; + return source; + } + if (get(source, 'originPlugin', undefined) === undefined) { + source.error = 'originPlugin is missing in augment-vis saved object'; + return source; + } + if (get(source, 'pluginResource.type', undefined) === undefined) { + source.error = 'pluginResource.type is missing in augment-vis saved object'; + return source; + } + if (get(source, 'pluginResource.id', undefined) === undefined) { + source.error = 'pluginResource.id is missing in augment-vis saved object'; + return source; + } + return source; + }; + + /** + * Updates hit.attributes to contain an id related to the referenced visualization + * (visId) and returns the updated attributes object. + * @param hit + * @returns {hit.attributes} The modified hit.attributes object, with an id and url field. + */ + mapSavedObjectApiHits(hit: { + references: any[]; + attributes: AugmentVisSavedObjectAttributes; + id: string; + }) { + // For now we are assuming only one vis reference per saved object. + // If we change to multiple, we will need to dynamically handle that + const visReference = hit.references[0]; + return this.mapHitSource({ ...hit.attributes, visReference }, hit.id); + } + + /** + * Retrieve a saved object by id or create new one. + * Returns a promise that completes when the object finishes + * initializing. Throws exception when the setting is set to false. + * @param opts + * @returns {Promise} + */ + get(opts?: Record | string) { + this.isAugmentationEnabled(); + return super.get(opts); + } + + /** + * TODO: Rather than use a hardcoded limit, implement pagination. See + * https://github.com/elastic/kibana/issues/8044 for reference. + * + * @param search + * @param size + * @param fields + * @returns {Promise} + */ + findAll(search: string = '', size: number = 100, fields?: string[]) { + this.isAugmentationEnabled(); + return super.findAll(search, size, fields); + } + + find(search: string = '', size: number = 100) { + this.isAugmentationEnabled(); + return super.find(search, size); + } + + private isAugmentationEnabled() { + const isAugmentationEnabled = this.config.get(PLUGIN_AUGMENTATION_ENABLE_SETTING, true); + if (!isAugmentationEnabled) { + throw new Error( + 'Visualization augmentation is disabled, please enable visualization:enablePluginAugmentation.' + ); + } + } +} + +export function createSavedAugmentVisLoader( + services: SavedObjectOpenSearchDashboardsServicesWithAugmentVis +) { + const { savedObjectsClient } = services; + + const SavedAugmentVis = createSavedAugmentVisClass(services); + return new SavedObjectLoaderAugmentVis(SavedAugmentVis, savedObjectsClient); +} diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.test.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.test.ts new file mode 100644 index 00000000000..dd7aef79d9a --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + extractReferences, + injectReferences, + VIS_REFERENCE_NAME, +} from './saved_augment_vis_references'; +import { AugmentVisSavedObject } from './types'; + +describe('extractReferences()', () => { + test('extracts nothing if visId is null', () => { + const doc = { + id: '1', + attributes: { + foo: true, + }, + references: [], + }; + const updatedDoc = extractReferences(doc); + expect(updatedDoc).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "foo": true, + }, + "references": Array [], + } + `); + }); + + test('extracts references from visId', () => { + const doc = { + id: '1', + attributes: { + foo: true, + visId: 'test-id', + }, + references: [], + }; + const updatedDoc = extractReferences(doc); + expect(updatedDoc).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "foo": true, + "visName": "visualization_0", + }, + "references": Array [ + Object { + "id": "test-id", + "name": "visualization_0", + "type": "visualization", + }, + ], + } + `); + }); +}); + +describe('injectReferences()', () => { + test('injects nothing when visName is null', () => { + const context = ({ + id: '1', + pluginResourceId: 'test-resource-id', + visLayerExpressionFn: 'test-fn', + } as unknown) as AugmentVisSavedObject; + injectReferences(context, []); + expect(context).toMatchInlineSnapshot(` + Object { + "id": "1", + "pluginResourceId": "test-resource-id", + "visLayerExpressionFn": "test-fn", + } + `); + }); + + test('injects references into context', () => { + const context = ({ + id: '1', + pluginResourceId: 'test-resource-id', + visLayerExpressionFn: 'test-fn', + visName: VIS_REFERENCE_NAME, + } as unknown) as AugmentVisSavedObject; + const references = [ + { + name: VIS_REFERENCE_NAME, + type: 'visualization', + id: 'test-id', + }, + ]; + injectReferences(context, references); + expect(context).toMatchInlineSnapshot(` + Object { + "id": "1", + "pluginResourceId": "test-resource-id", + "visId": "test-id", + "visLayerExpressionFn": "test-fn", + } + `); + }); + + test(`fails when it can't find the saved object reference in the array`, () => { + const context = ({ + id: '1', + pluginResourceId: 'test-resource-id', + visLayerExpressionFn: 'test-fn', + visName: VIS_REFERENCE_NAME, + } as unknown) as AugmentVisSavedObject; + expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot( + `"Could not find visualization reference \\"${VIS_REFERENCE_NAME}\\""` + ); + }); +}); diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.ts new file mode 100644 index 00000000000..7a915f93745 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.ts @@ -0,0 +1,75 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/public'; +import { AugmentVisSavedObjectAttributes } from '../../common'; +import { AugmentVisSavedObject } from './types'; + +/** + * Note that references aren't stored in the object's client-side interface (AugmentVisSavedObject). + * Rather, just the ID/type is. The concept of references is a server-side definition used to define + * relationships between saved objects. They are visible in the saved objs management page or + * when making direct saved obj API calls. + * + * So, we need helper fns to construct & deconstruct references when creating and reading the + * indexed/stored saved objects, respectively. + */ + +/** + * Using a constant value for the visualization name to easily extact/inject + * the reference. Setting as "_0" which could be expanded and incremented upon + * in the future if we decide to persist multiple visualizations per + * AugmentVisSavedObject. + */ +export const VIS_REFERENCE_NAME = 'visualization_0'; + +/** + * Used during creation. Converting from AugmentVisSavedObject to the actual indexed saved object + * with references. + */ +export function extractReferences({ + attributes, + references = [], +}: { + attributes: SavedObjectAttributes; + references: SavedObjectReference[]; +}) { + const updatedAttributes = { ...attributes } as AugmentVisSavedObjectAttributes; + const updatedReferences = [...references]; + + // Extract saved object + if (updatedAttributes.visId) { + updatedReferences.push({ + name: VIS_REFERENCE_NAME, + type: 'visualization', + id: String(updatedAttributes.visId), + }); + delete updatedAttributes.visId; + + updatedAttributes.visName = VIS_REFERENCE_NAME; + } + return { + references: updatedReferences, + attributes: updatedAttributes, + }; +} + +/** + * Used during reading. Converting from the indexed saved object with references + * to a AugmentVisSavedObject + */ +export function injectReferences( + savedObject: AugmentVisSavedObject, + references: SavedObjectReference[] +) { + if (savedObject.visName) { + const visReference = references.find((reference) => reference.name === savedObject.visName); + if (!visReference) { + throw new Error(`Could not find visualization reference "${savedObject.visName}"`); + } + savedObject.visId = visReference.id; + delete savedObject.visName; + } +} diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/types.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/types.ts new file mode 100644 index 00000000000..c2e5b8b1900 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/types.ts @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObject } from '../../../saved_objects/public'; +import { VisLayerExpressionFn } from '../expressions'; + +export interface ISavedPluginResource { + type: string; + id: string; +} + +export interface ISavedAugmentVis { + id?: string; + title: string; + description?: string; + originPlugin: string; + pluginResource: ISavedPluginResource; + visName?: string; + visId?: string; + visLayerExpressionFn: VisLayerExpressionFn; + version?: number; +} + +export interface AugmentVisSavedObject extends SavedObject, ISavedAugmentVis {} diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/utils/helpers.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/helpers.ts new file mode 100644 index 00000000000..0751f1dd330 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/helpers.ts @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get } from 'lodash'; +import { ISavedAugmentVis } from '../types'; +import { + PLUGIN_AUGMENTATION_ENABLE_SETTING, + PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING, +} from '../../../common/constants'; +import { SavedAugmentVisLoader } from '../saved_augment_vis'; +import { getSavedAugmentVisLoader, getUISettings } from '../../services'; +import { IUiSettingsClient } from '../../../../../core/public'; + +/** + * Create an augment vis saved object given an object that + * implements the ISavedAugmentVis interface + */ +export const createAugmentVisSavedObject = async ( + AugmentVis: ISavedAugmentVis, + savedObjLoader?: SavedAugmentVisLoader, + uiSettings?: IUiSettingsClient +): Promise => { + // Using optional services provided, or the built-in services from this plugin + const loader = savedObjLoader !== undefined ? savedObjLoader : getSavedAugmentVisLoader(); + const config = uiSettings !== undefined ? uiSettings : getUISettings(); + const isAugmentationEnabled = config.get(PLUGIN_AUGMENTATION_ENABLE_SETTING); + if (!isAugmentationEnabled) { + throw new Error( + 'Visualization augmentation is disabled, please enable visualization:enablePluginAugmentation.' + ); + } + const maxAssociatedCount = config.get(PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING); + + await loader.findAll().then(async (resp) => { + if (resp !== undefined) { + const savedAugmentObjects = get(resp, 'hits', []); + // gets all the saved object for this visualization + const savedObjectsForThisVisualization = savedAugmentObjects.filter( + (savedObj) => get(savedObj, 'visId', '') === AugmentVis.visId + ); + + if (maxAssociatedCount <= savedObjectsForThisVisualization.length) { + throw new Error( + `Cannot associate the plugin resource to the visualization due to the limit of the max + amount of associated plugin resources (${maxAssociatedCount}) with + ${savedObjectsForThisVisualization.length} associated to the visualization` + ); + } + } + }); + + return await loader.get((AugmentVis as any) as Record); +}; diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/utils/index.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/index.ts new file mode 100644 index 00000000000..aa4d6dbf3e3 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './helpers'; +export * from './test_helpers'; diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/utils/test_helpers.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/test_helpers.ts new file mode 100644 index 00000000000..c162fd3e0a6 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/test_helpers.ts @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cloneDeep } from 'lodash'; +import { VisLayerExpressionFn, ISavedAugmentVis, ISavedPluginResource } from '../../'; +import { VIS_REFERENCE_NAME } from '../saved_augment_vis_references'; + +const title = 'test-title'; +const version = 1; + +export const generateAugmentVisSavedObject = ( + idArg: string, + exprFnArg: VisLayerExpressionFn, + visIdArg: string, + originPluginArg: string, + pluginResourceArg: ISavedPluginResource +) => { + return { + id: idArg, + title, + originPlugin: originPluginArg, + pluginResource: pluginResourceArg, + visLayerExpressionFn: exprFnArg, + VIS_REFERENCE_NAME, + visId: visIdArg, + version, + } as ISavedAugmentVis; +}; + +export const getMockAugmentVisSavedObjectClient = ( + augmentVisSavedObjs: ISavedAugmentVis[], + keepReferences: boolean = true +): any => { + const savedObjs = (augmentVisSavedObjs = cloneDeep(augmentVisSavedObjs)); + + const client = { + find: jest.fn(() => + Promise.resolve({ + total: savedObjs.length, + savedObjects: savedObjs.map((savedObj) => { + const objVisId = savedObj.visId; + const objId = savedObj.id; + delete savedObj.visId; + delete savedObj.id; + return { + id: objId, + attributes: savedObj as Record, + references: keepReferences + ? [ + { + name: savedObj.visName, + type: 'visualization', + id: objVisId, + }, + ] + : [], + }; + }), + }) + ), + } as any; + return client; +}; diff --git a/src/plugins/vis_augmenter/public/services.ts b/src/plugins/vis_augmenter/public/services.ts new file mode 100644 index 00000000000..1d7f3e2111d --- /dev/null +++ b/src/plugins/vis_augmenter/public/services.ts @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; +import { IUiSettingsClient } from '../../../core/public'; +import { SavedObjectLoaderAugmentVis } from './saved_augment_vis'; +import { EmbeddableStart } from '../../embeddable/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< + SavedObjectLoaderAugmentVis +>('savedAugmentVisLoader'); + +export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); + +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'); + +// This is primarily used for mocking this module and each of its fns in tests. +// eslint-disable-next-line import/no-default-export +export default { + getSavedAugmentVisLoader, + getUISettings, + getUiActions, + getEmbeddable, + getQueryService, + getVisualizations, + getCore, +}; diff --git a/src/plugins/vis_augmenter/public/test_constants.ts b/src/plugins/vis_augmenter/public/test_constants.ts new file mode 100644 index 00000000000..fdd3fa4373a --- /dev/null +++ b/src/plugins/vis_augmenter/public/test_constants.ts @@ -0,0 +1,669 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenSearchDashboardsDatatable } from '../../expressions/public'; +import { VisAnnotationType } from './vega/constants'; +import { + VIS_LAYER_COLUMN_TYPE, + VisLayerTypes, + HOVER_PARAM, + EVENT_MARK_SIZE, + EVENT_MARK_SIZE_ENLARGED, + EVENT_COLOR, + EVENT_MARK_SHAPE, +} from './'; + +const TEST_X_AXIS_ID = 'test-x-axis-id'; +const TEST_X_AXIS_ID_DIRTY = 'test.x.axis.id'; +const TEST_VALUE_AXIS_ID = 'test-value-axis-id'; +const TEST_VALUE_AXIS_ID_DIRTY = 'test.value.axis.id'; +const TEST_X_AXIS_TITLE = 'time'; +const TEST_VALUE_AXIS_TITLE = 'avg value'; +const TEST_PLUGIN = 'test-plugin'; +const TEST_PLUGIN_EVENT_TYPE = 'test-plugin-event-type'; +const TEST_PLUGIN_EVENT_TYPE_2 = 'test-plugin-event-type-2'; +const TEST_PLUGIN_RESOURCE_TYPE = 'test-resource-type'; +const TEST_PLUGIN_RESOURCE_ID = 'test-resource-id'; +const TEST_PLUGIN_RESOURCE_ID_2 = 'test-resource-id-2'; +const TEST_PLUGIN_RESOURCE_NAME = 'test-resource-name'; +const TEST_PLUGIN_RESOURCE_NAME_2 = 'test-resource-name-2'; +const TEST_PLUGIN_RESOURCE_PATH = `${TEST_PLUGIN}/${TEST_PLUGIN_RESOURCE_TYPE}/${TEST_PLUGIN_RESOURCE_ID}`; +const TEST_PLUGIN_RESOURCE_PATH_2 = `${TEST_PLUGIN}/${TEST_PLUGIN_RESOURCE_TYPE}/${TEST_PLUGIN_RESOURCE_ID_2}`; + +const TEST_VALUES_SINGLE_ROW_NO_VIS_LAYERS = [{ [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5 }]; + +const TEST_VALUES_SINGLE_ROW_SINGLE_VIS_LAYER = [ + { [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 3 }, +]; + +const TEST_VALUES_ONLY_VIS_LAYERS = [ + { [TEST_X_AXIS_ID]: 0, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 2 }, + { [TEST_X_AXIS_ID]: 10, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 15, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 20, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 25, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 30, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 35, [TEST_PLUGIN_EVENT_TYPE]: 1 }, + { [TEST_X_AXIS_ID]: 40, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 45, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 50, [TEST_PLUGIN_EVENT_TYPE]: 0 }, +]; + +const TEST_VALUES_NO_VIS_LAYERS = [ + { [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5 }, + { [TEST_X_AXIS_ID]: 5, [TEST_VALUE_AXIS_ID]: 10 }, + { [TEST_X_AXIS_ID]: 10, [TEST_VALUE_AXIS_ID]: 6 }, + { [TEST_X_AXIS_ID]: 15, [TEST_VALUE_AXIS_ID]: 4 }, + { [TEST_X_AXIS_ID]: 20, [TEST_VALUE_AXIS_ID]: 5 }, + { [TEST_X_AXIS_ID]: 25 }, + { [TEST_X_AXIS_ID]: 30 }, + { [TEST_X_AXIS_ID]: 35 }, + { [TEST_X_AXIS_ID]: 40 }, + { [TEST_X_AXIS_ID]: 45, [TEST_VALUE_AXIS_ID]: 3 }, + { [TEST_X_AXIS_ID]: 50, [TEST_VALUE_AXIS_ID]: 5 }, +]; + +const TEST_VALUES_SINGLE_VIS_LAYER_EMPTY = [ + { [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 5, [TEST_VALUE_AXIS_ID]: 10, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 10, [TEST_VALUE_AXIS_ID]: 6, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 15, [TEST_VALUE_AXIS_ID]: 4, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 20, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 25, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 30, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 35, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 40, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 45, [TEST_VALUE_AXIS_ID]: 3, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 50, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 0 }, +]; + +const TEST_VALUES_NO_VIS_LAYERS_DIRTY = [ + { [TEST_X_AXIS_ID_DIRTY]: 0, [TEST_VALUE_AXIS_ID_DIRTY]: 5 }, + { [TEST_X_AXIS_ID_DIRTY]: 5, [TEST_VALUE_AXIS_ID_DIRTY]: 10 }, + { [TEST_X_AXIS_ID_DIRTY]: 10, [TEST_VALUE_AXIS_ID_DIRTY]: 6 }, + { [TEST_X_AXIS_ID_DIRTY]: 15, [TEST_VALUE_AXIS_ID_DIRTY]: 4 }, + { [TEST_X_AXIS_ID_DIRTY]: 20, [TEST_VALUE_AXIS_ID_DIRTY]: 5 }, + { [TEST_X_AXIS_ID_DIRTY]: 25 }, + { [TEST_X_AXIS_ID_DIRTY]: 30 }, + { [TEST_X_AXIS_ID_DIRTY]: 35 }, + { [TEST_X_AXIS_ID_DIRTY]: 40 }, + { [TEST_X_AXIS_ID_DIRTY]: 45, [TEST_VALUE_AXIS_ID_DIRTY]: 3 }, + { [TEST_X_AXIS_ID_DIRTY]: 50, [TEST_VALUE_AXIS_ID_DIRTY]: 5 }, +]; + +const TEST_VALUES_SINGLE_VIS_LAYER = [ + { [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 5, [TEST_VALUE_AXIS_ID]: 10, [TEST_PLUGIN_EVENT_TYPE]: 2 }, + { [TEST_X_AXIS_ID]: 10, [TEST_VALUE_AXIS_ID]: 6, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 15, [TEST_VALUE_AXIS_ID]: 4, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 20, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 25, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 30, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 35, [TEST_PLUGIN_EVENT_TYPE]: 1 }, + { [TEST_X_AXIS_ID]: 40, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 45, [TEST_VALUE_AXIS_ID]: 3, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 50, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 0 }, +]; + +const TEST_VALUES_SINGLE_VIS_LAYER_ON_BOUNDS = [ + { [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 2 }, + { [TEST_X_AXIS_ID]: 5, [TEST_VALUE_AXIS_ID]: 10, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 10, [TEST_VALUE_AXIS_ID]: 6, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 15, [TEST_VALUE_AXIS_ID]: 4, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 20, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 25, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 30, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 35, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 40, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 45, [TEST_VALUE_AXIS_ID]: 3, [TEST_PLUGIN_EVENT_TYPE]: 0 }, + { [TEST_X_AXIS_ID]: 50, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_EVENT_TYPE]: 1 }, +]; + +const TEST_VALUES_MULTIPLE_VIS_LAYERS = [ + { + [TEST_X_AXIS_ID]: 0, + [TEST_VALUE_AXIS_ID]: 5, + [TEST_PLUGIN_EVENT_TYPE]: 0, + [TEST_PLUGIN_EVENT_TYPE_2]: 0, + }, + { + [TEST_X_AXIS_ID]: 5, + [TEST_VALUE_AXIS_ID]: 10, + [TEST_PLUGIN_EVENT_TYPE]: 2, + [TEST_PLUGIN_EVENT_TYPE_2]: 1, + }, + { + [TEST_X_AXIS_ID]: 10, + [TEST_VALUE_AXIS_ID]: 6, + [TEST_PLUGIN_EVENT_TYPE]: 0, + [TEST_PLUGIN_EVENT_TYPE_2]: 0, + }, + { + [TEST_X_AXIS_ID]: 15, + [TEST_VALUE_AXIS_ID]: 4, + [TEST_PLUGIN_EVENT_TYPE]: 0, + [TEST_PLUGIN_EVENT_TYPE_2]: 1, + }, + { + [TEST_X_AXIS_ID]: 20, + [TEST_VALUE_AXIS_ID]: 5, + [TEST_PLUGIN_EVENT_TYPE]: 0, + [TEST_PLUGIN_EVENT_TYPE_2]: 0, + }, + { [TEST_X_AXIS_ID]: 25, [TEST_PLUGIN_EVENT_TYPE]: 0, [TEST_PLUGIN_EVENT_TYPE_2]: 0 }, + { [TEST_X_AXIS_ID]: 30, [TEST_PLUGIN_EVENT_TYPE]: 0, [TEST_PLUGIN_EVENT_TYPE_2]: 0 }, + { [TEST_X_AXIS_ID]: 35, [TEST_PLUGIN_EVENT_TYPE]: 1, [TEST_PLUGIN_EVENT_TYPE_2]: 0 }, + { [TEST_X_AXIS_ID]: 40, [TEST_PLUGIN_EVENT_TYPE]: 0, [TEST_PLUGIN_EVENT_TYPE_2]: 0 }, + { + [TEST_X_AXIS_ID]: 45, + [TEST_VALUE_AXIS_ID]: 3, + [TEST_PLUGIN_EVENT_TYPE]: 0, + [TEST_PLUGIN_EVENT_TYPE_2]: 0, + }, + { + [TEST_X_AXIS_ID]: 50, + [TEST_VALUE_AXIS_ID]: 5, + [TEST_PLUGIN_EVENT_TYPE]: 0, + [TEST_PLUGIN_EVENT_TYPE_2]: 2, + }, +]; + +export const TEST_COLUMNS_NO_VIS_LAYERS = [ + { + id: TEST_X_AXIS_ID, + name: TEST_X_AXIS_TITLE, + }, + { + id: TEST_VALUE_AXIS_ID, + name: TEST_VALUE_AXIS_TITLE, + }, +]; + +export const TEST_COLUMNS_NO_VIS_LAYERS_DIRTY = [ + { + id: TEST_X_AXIS_ID_DIRTY, + name: TEST_X_AXIS_TITLE, + }, + { + id: TEST_VALUE_AXIS_ID_DIRTY, + name: TEST_VALUE_AXIS_TITLE, + }, +]; + +export const TEST_COLUMNS_SINGLE_VIS_LAYER = [ + ...TEST_COLUMNS_NO_VIS_LAYERS, + { + id: TEST_PLUGIN_EVENT_TYPE, + name: `${TEST_PLUGIN_EVENT_TYPE} count`, + meta: { + type: VIS_LAYER_COLUMN_TYPE, + }, + }, +]; + +export const TEST_COLUMNS_MULTIPLE_VIS_LAYERS = [ + ...TEST_COLUMNS_SINGLE_VIS_LAYER, + { + id: TEST_PLUGIN_EVENT_TYPE_2, + name: `${TEST_PLUGIN_EVENT_TYPE_2} count`, + meta: { + type: VIS_LAYER_COLUMN_TYPE, + }, + }, +]; + +export const TEST_DATATABLE_SINGLE_ROW_NO_VIS_LAYERS = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_NO_VIS_LAYERS, + rows: TEST_VALUES_SINGLE_ROW_NO_VIS_LAYERS, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_SINGLE_ROW_SINGLE_VIS_LAYER = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_SINGLE_VIS_LAYER, + rows: TEST_VALUES_SINGLE_ROW_SINGLE_VIS_LAYER, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_ONLY_VIS_LAYERS = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_SINGLE_VIS_LAYER, + rows: TEST_VALUES_ONLY_VIS_LAYERS, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_NO_VIS_LAYERS = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_NO_VIS_LAYERS, + rows: TEST_VALUES_NO_VIS_LAYERS, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_NO_VIS_LAYERS_DIRTY = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_NO_VIS_LAYERS_DIRTY, + rows: TEST_VALUES_NO_VIS_LAYERS_DIRTY, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_SINGLE_VIS_LAYER_EMPTY = { + ...TEST_DATATABLE_NO_VIS_LAYERS, + columns: TEST_COLUMNS_SINGLE_VIS_LAYER, + rows: TEST_VALUES_SINGLE_VIS_LAYER_EMPTY, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_SINGLE_VIS_LAYER = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_SINGLE_VIS_LAYER, + rows: TEST_VALUES_SINGLE_VIS_LAYER, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_SINGLE_VIS_LAYER_ON_BOUNDS = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_SINGLE_VIS_LAYER, + rows: TEST_VALUES_SINGLE_VIS_LAYER_ON_BOUNDS, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_MULTIPLE_VIS_LAYERS = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_MULTIPLE_VIS_LAYERS, + rows: TEST_VALUES_MULTIPLE_VIS_LAYERS, +} as OpenSearchDashboardsDatatable; + +const TEST_BASE_CONFIG = { + view: { stroke: null }, + concat: { spacing: 0 }, + legend: { orient: 'right' }, + kibana: { hideWarnings: true }, +}; + +const TEST_BASE_VIS_LAYER = { + mark: { type: 'line', interpolate: 'linear', strokeWidth: 2, point: true }, + encoding: { + x: { + axis: { title: TEST_X_AXIS_TITLE, grid: false }, + field: TEST_X_AXIS_ID, + type: 'temporal', + }, + y: { + axis: { + title: TEST_VALUE_AXIS_TITLE, + grid: '', + orient: 'left', + labels: true, + labelAngle: 0, + }, + field: TEST_VALUE_AXIS_ID, + type: 'quantitative', + }, + tooltip: [ + { field: TEST_X_AXIS_ID, type: 'temporal', title: TEST_VALUE_AXIS_TITLE }, + { field: TEST_VALUE_AXIS_ID, type: 'quantitative', title: TEST_VALUE_AXIS_TITLE }, + ], + color: { datum: TEST_VALUE_AXIS_TITLE }, + }, +}; + +export const TEST_SPEC_NO_VIS_LAYERS = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: TEST_VALUES_NO_VIS_LAYERS, + }, + config: TEST_BASE_CONFIG, + layer: [TEST_BASE_VIS_LAYER], +}; + +export const TEST_SPEC_SINGLE_VIS_LAYER = { + ...TEST_SPEC_NO_VIS_LAYERS, + data: { + ...TEST_SPEC_NO_VIS_LAYERS.data, + values: TEST_VALUES_SINGLE_VIS_LAYER, + }, +}; + +export const TEST_SPEC_MULTIPLE_VIS_LAYERS = { + ...TEST_SPEC_NO_VIS_LAYERS, + data: { + ...TEST_SPEC_NO_VIS_LAYERS.data, + values: TEST_VALUES_MULTIPLE_VIS_LAYERS, + }, +}; + +export const TEST_DIMENSIONS = { + x: { + params: { + interval: 5, + bounds: { + min: 0, + max: 50, + }, + }, + label: TEST_X_AXIS_TITLE, + }, +}; + +export const TEST_DIMENSIONS_SINGLE_ROW = { + x: { + params: { + interval: 5, + bounds: { + min: 0, + max: 0, + }, + }, + label: TEST_X_AXIS_TITLE, + }, +}; + +export const TEST_DIMENSIONS_INVALID_BOUNDS = { + x: { + params: { + interval: 5, + bounds: { + min: 50, + max: 0, + }, + }, + label: TEST_X_AXIS_TITLE, + }, +}; + +export const TEST_VIS_LAYERS_SINGLE = [ + { + originPlugin: TEST_PLUGIN, + type: VisLayerTypes.PointInTimeEvents, + pluginResource: { + type: TEST_PLUGIN_RESOURCE_TYPE, + id: TEST_PLUGIN_RESOURCE_ID, + name: TEST_PLUGIN_RESOURCE_NAME, + urlPath: TEST_PLUGIN_RESOURCE_PATH, + }, + pluginEventType: TEST_PLUGIN_EVENT_TYPE, + events: [ + { + timestamp: 4, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + { + timestamp: 6, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + { + timestamp: 35, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + ], + }, +]; + +export const TEST_VIS_LAYERS_SINGLE_INVALID_BOUNDS = [ + { + originPlugin: TEST_PLUGIN, + type: VisLayerTypes.PointInTimeEvents, + pluginResource: { + type: TEST_PLUGIN_RESOURCE_TYPE, + id: TEST_PLUGIN_RESOURCE_ID, + name: TEST_PLUGIN_RESOURCE_NAME, + urlPath: TEST_PLUGIN_RESOURCE_PATH, + }, + pluginEventType: TEST_PLUGIN_EVENT_TYPE, + events: [ + { + timestamp: -5, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + { + timestamp: -100, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + { + timestamp: 75, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + ], + }, +]; + +export const TEST_VIS_LAYERS_SINGLE_ON_BOUNDS = [ + { + originPlugin: TEST_PLUGIN, + type: VisLayerTypes.PointInTimeEvents, + pluginResource: { + type: TEST_PLUGIN_RESOURCE_TYPE, + id: TEST_PLUGIN_RESOURCE_ID, + name: TEST_PLUGIN_RESOURCE_NAME, + urlPath: TEST_PLUGIN_RESOURCE_PATH, + }, + pluginEventType: TEST_PLUGIN_EVENT_TYPE, + events: [ + { + timestamp: 0, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + { + timestamp: 2, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + { + timestamp: 55, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + ], + }, +]; + +export const TEST_VIS_LAYERS_MULTIPLE = [ + ...TEST_VIS_LAYERS_SINGLE, + { + originPlugin: TEST_PLUGIN, + type: VisLayerTypes.PointInTimeEvents, + pluginResource: { + type: TEST_PLUGIN_RESOURCE_TYPE, + id: TEST_PLUGIN_RESOURCE_ID_2, + name: TEST_PLUGIN_RESOURCE_NAME_2, + urlPath: TEST_PLUGIN_RESOURCE_PATH_2, + }, + pluginEventType: TEST_PLUGIN_EVENT_TYPE_2, + events: [ + { + timestamp: 5, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID_2, + }, + }, + { + timestamp: 15, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID_2, + }, + }, + { + timestamp: 49, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID_2, + }, + }, + { + timestamp: 50, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID_2, + }, + }, + ], + }, +]; + +const TEST_RULE_LAYER_SINGLE_VIS_LAYER = { + mark: { type: 'rule', color: EVENT_COLOR, opacity: 1 }, + transform: [{ filter: `datum['${TEST_PLUGIN_EVENT_TYPE}'] > 0` }], + encoding: { + x: { field: TEST_X_AXIS_ID, type: 'temporal' }, + opacity: { value: 0, condition: { empty: false, param: HOVER_PARAM, value: 1 } }, + }, +}; + +const TEST_RULE_LAYER_MULTIPLE_VIS_LAYERS = { + ...TEST_RULE_LAYER_SINGLE_VIS_LAYER, + transform: [ + { + filter: `datum['${TEST_PLUGIN_EVENT_TYPE}'] > 0 || datum['${TEST_PLUGIN_EVENT_TYPE_2}'] > 0`, + }, + ], +}; + +const TEST_EVENTS_LAYER_SINGLE_VIS_LAYER = { + height: 25, + mark: { + type: 'point', + shape: EVENT_MARK_SHAPE, + fill: EVENT_COLOR, + fillOpacity: 1, + stroke: EVENT_COLOR, + strokeOpacity: 1, + style: [`${VisAnnotationType.POINT_IN_TIME_ANNOTATION}`], + tooltip: true, + }, + transform: [ + { filter: `datum['${TEST_PLUGIN_EVENT_TYPE}'] > 0` }, + { calculate: `'${VisAnnotationType.POINT_IN_TIME_ANNOTATION}'`, as: 'annotationType' }, + ], + params: [{ name: HOVER_PARAM, select: { type: 'point', on: 'mouseover' } }], + encoding: { + x: { + axis: { + title: TEST_X_AXIS_TITLE, + grid: false, + ticks: true, + orient: 'bottom', + domain: true, + }, + field: TEST_X_AXIS_ID, + type: 'temporal', + scale: { + domain: [ + { + year: 2022, + month: 'December', + date: 1, + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + }, + { + year: 2023, + month: 'March', + date: 2, + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + }, + ], + }, + }, + size: { + condition: { empty: false, param: HOVER_PARAM, value: EVENT_MARK_SIZE_ENLARGED }, + value: EVENT_MARK_SIZE, + }, + tooltip: [{ field: TEST_PLUGIN_EVENT_TYPE }], + }, +}; + +const TEST_EVENTS_LAYER_MULTIPLE_VIS_LAYERS = { + ...TEST_EVENTS_LAYER_SINGLE_VIS_LAYER, + transform: [ + { + filter: `datum['${TEST_PLUGIN_EVENT_TYPE}'] > 0 || datum['${TEST_PLUGIN_EVENT_TYPE_2}'] > 0`, + }, + { calculate: `'${VisAnnotationType.POINT_IN_TIME_ANNOTATION}'`, as: 'annotationType' }, + ], + encoding: { + ...TEST_EVENTS_LAYER_SINGLE_VIS_LAYER.encoding, + tooltip: [{ field: TEST_PLUGIN_EVENT_TYPE }, { field: TEST_PLUGIN_EVENT_TYPE_2 }], + }, +}; + +export const TEST_RESULT_SPEC_SINGLE_VIS_LAYER = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: TEST_VALUES_SINGLE_VIS_LAYER, + }, + config: TEST_BASE_CONFIG, + vconcat: [ + { + layer: [ + { + ...TEST_BASE_VIS_LAYER, + encoding: { + ...TEST_BASE_VIS_LAYER.encoding, + x: { + ...TEST_BASE_VIS_LAYER.encoding.x, + axis: { + title: null, + grid: false, + labels: false, + }, + }, + }, + }, + TEST_RULE_LAYER_SINGLE_VIS_LAYER, + ], + }, + TEST_EVENTS_LAYER_SINGLE_VIS_LAYER, + ], +}; + +export const TEST_RESULT_SPEC_SINGLE_VIS_LAYER_EMPTY = { + ...TEST_RESULT_SPEC_SINGLE_VIS_LAYER, + data: { + values: TEST_VALUES_NO_VIS_LAYERS, + }, +}; + +export const TEST_RESULT_SPEC_MULTIPLE_VIS_LAYERS = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: TEST_VALUES_MULTIPLE_VIS_LAYERS, + }, + config: TEST_BASE_CONFIG, + vconcat: [ + { + layer: [ + { + ...TEST_BASE_VIS_LAYER, + encoding: { + ...TEST_BASE_VIS_LAYER.encoding, + x: { + ...TEST_BASE_VIS_LAYER.encoding.x, + axis: { + title: null, + grid: false, + labels: false, + }, + }, + }, + }, + TEST_RULE_LAYER_MULTIPLE_VIS_LAYERS, + ], + }, + TEST_EVENTS_LAYER_MULTIPLE_VIS_LAYERS, + ], +}; diff --git a/src/plugins/vis_augmenter/public/triggers/index.ts b/src/plugins/vis_augmenter/public/triggers/index.ts new file mode 100644 index 00000000000..5b1833e38d6 --- /dev/null +++ b/src/plugins/vis_augmenter/public/triggers/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + PLUGIN_RESOURCE_DELETE_TRIGGER, + pluginResourceDeleteTrigger, +} from './plugin_resource_delete_trigger'; diff --git a/src/plugins/vis_augmenter/public/triggers/plugin_resource_delete_trigger.ts b/src/plugins/vis_augmenter/public/triggers/plugin_resource_delete_trigger.ts new file mode 100644 index 00000000000..249bb61132e --- /dev/null +++ b/src/plugins/vis_augmenter/public/triggers/plugin_resource_delete_trigger.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Trigger } from '../../../ui_actions/public'; + +export const PLUGIN_RESOURCE_DELETE_TRIGGER = 'PLUGIN_RESOURCE_DELETE_TRIGGER'; +export const pluginResourceDeleteTrigger: Trigger<'PLUGIN_RESOURCE_DELETE_TRIGGER'> = { + id: PLUGIN_RESOURCE_DELETE_TRIGGER, + title: i18n.translate('visAugmenter.triggers.pluginResourceDeleteTitle', { + defaultMessage: 'Plugin resource delete', + }), + description: i18n.translate('visAugmenter.triggers.pluginResourceDeleteDescription', { + defaultMessage: 'Delete augment-vis saved objs associated to the deleted plugin resource', + }), +}; diff --git a/src/plugins/vis_augmenter/public/types.test.ts b/src/plugins/vis_augmenter/public/types.test.ts new file mode 100644 index 00000000000..4bd4cb1221c --- /dev/null +++ b/src/plugins/vis_augmenter/public/types.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + VisLayerTypes, + isPointInTimeEventsVisLayer, + isValidVisLayer, + isVisLayerWithError, +} from './types'; +import { createVisLayer } from './mocks'; + +describe('isPointInTimeEventsVisLayer()', function () { + it('should return false if type does not match', function () { + const visLayer = createVisLayer('unknown-vis-layer-type'); + expect(isPointInTimeEventsVisLayer(visLayer)).toBe(false); + }); + + it('should return true if type matches', function () { + const visLayer = createVisLayer(VisLayerTypes.PointInTimeEvents); + expect(isPointInTimeEventsVisLayer(visLayer)).toBe(true); + }); +}); + +describe('isValidVisLayer()', function () { + it('should return false if no valid type', function () { + const visLayer = createVisLayer('unknown-vis-layer-type'); + expect(isValidVisLayer(visLayer)).toBe(false); + }); + + it('should return true if type matches', function () { + const visLayer = createVisLayer(VisLayerTypes.PointInTimeEvents); + expect(isValidVisLayer(visLayer)).toBe(true); + }); +}); + +describe('isVisLayerWithError()', function () { + it('should return false if no error', function () { + const visLayer = createVisLayer('unknown-vis-layer-type', false); + expect(isVisLayerWithError(visLayer)).toBe(false); + }); + + it('should return true if error', function () { + const visLayer = createVisLayer(VisLayerTypes.PointInTimeEvents, true); + expect(isVisLayerWithError(visLayer)).toBe(true); + }); +}); diff --git a/src/plugins/vis_augmenter/public/types.ts b/src/plugins/vis_augmenter/public/types.ts new file mode 100644 index 00000000000..35c00ea932a --- /dev/null +++ b/src/plugins/vis_augmenter/public/types.ts @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum VisLayerTypes { + PointInTimeEvents = 'PointInTimeEvents', +} + +export enum VisLayerErrorTypes { + PERMISSIONS_FAILURE = 'PERMISSIONS_FAILURE', + FETCH_FAILURE = 'FETCH_FAILURE', + RESOURCE_DELETED = 'RESOURCE_DELETED', +} + +export enum VisFlyoutContext { + BASE_VIS = 'BASE_VIS', + EVENT_VIS = 'EVENT_VIS', + TIMELINE_VIS = 'TIMELINE_VIS', +} + +export interface VisLayerError { + type: keyof typeof VisLayerErrorTypes; + message: string; +} + +export type PluginResourceType = string; + +export interface PluginResource { + type: PluginResourceType; + id: string; + name: string; + urlPath: string; +} + +export interface VisLayer { + type: keyof typeof VisLayerTypes; + originPlugin: string; + pluginResource: PluginResource; + error?: VisLayerError; +} + +export type VisLayers = VisLayer[]; + +export interface EventMetadata { + pluginResourceId: string; +} + +export interface PointInTimeEvent { + timestamp: number; + metadata: EventMetadata; +} + +export interface PointInTimeEventsVisLayer extends VisLayer { + events: PointInTimeEvent[]; + pluginEventType: string; +} + +export const isPointInTimeEventsVisLayer = (obj: any) => { + return obj?.type === VisLayerTypes.PointInTimeEvents; +}; + +/** + * Used to determine if a given saved obj has a valid type and can + * be converted into a VisLayer + */ +export const isValidVisLayer = (obj: any) => { + return obj?.type in VisLayerTypes; +}; + +/** + * Used for checking if an existing VisLayer has a populated error field or not + */ +export const isVisLayerWithError = (visLayer: VisLayer): boolean => visLayer.error !== undefined; +// We need to have some extra config in order to render the charts correctly in different contexts. +// For example, we use the same base vis and modify it within the view events flyout to hide +// axes, only show events, only show timeline, add custom padding, etc. +// So, we abstract these concepts out and let the underlying implementation make changes as needed +// to support the different contexts. +export interface VisAugmenterEmbeddableConfig { + visLayerResourceIds?: string[]; + inFlyout?: boolean; + flyoutContext?: VisFlyoutContext; + leftValueAxisPadding?: boolean; + rightValueAxisPadding?: boolean; +} 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 00000000000..27bd6284e71 --- /dev/null +++ b/src/plugins/vis_augmenter/public/ui_actions_bootstrap.ts @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + OpenEventsFlyoutAction, + ViewEventsOptionAction, + OPEN_EVENTS_FLYOUT_ACTION, + VIEW_EVENTS_OPTION_ACTION, +} from './view_events_flyout'; +import { CONTEXT_MENU_TRIGGER, EmbeddableContext } from '../../embeddable/public'; +import { SAVED_OBJECT_DELETE_TRIGGER } from '../../saved_objects_management/public'; +import { + externalActionTrigger, + EXTERNAL_ACTION_TRIGGER, + UiActionsSetup, +} from '../../ui_actions/public'; +import { ISavedAugmentVis } from './saved_augment_vis'; +import { VisLayer } from './types'; +import { + PLUGIN_RESOURCE_DELETE_ACTION, + PluginResourceDeleteAction, + SAVED_OBJECT_DELETE_ACTION, + SavedObjectDeleteAction, +} from './actions'; +import { PLUGIN_RESOURCE_DELETE_TRIGGER, pluginResourceDeleteTrigger } from './triggers'; + +export interface AugmentVisContext { + savedObjectId: string; +} + +export interface SavedObjectDeleteContext { + type: string; + savedObjectId: string; +} + +export interface PluginResourceDeleteContext { + savedObjs: ISavedAugmentVis[]; + visLayers: VisLayer[]; +} + +// Overriding the mappings defined in UIActions plugin so that +// the new trigger and action definitions resolve. +// This is a common pattern among internal Dashboards plugins. +declare module '../../ui_actions/public' { + export interface TriggerContextMapping { + [EXTERNAL_ACTION_TRIGGER]: AugmentVisContext; + [PLUGIN_RESOURCE_DELETE_TRIGGER]: PluginResourceDeleteContext; + } + + export interface ActionContextMapping { + [OPEN_EVENTS_FLYOUT_ACTION]: AugmentVisContext; + [VIEW_EVENTS_OPTION_ACTION]: EmbeddableContext; + [SAVED_OBJECT_DELETE_ACTION]: SavedObjectDeleteContext; + [PLUGIN_RESOURCE_DELETE_ACTION]: PluginResourceDeleteContext; + } +} + +export const bootstrapUiActions = (uiActions: UiActionsSetup) => { + const openEventsFlyoutAction = new OpenEventsFlyoutAction(); + const viewEventsOptionAction = new ViewEventsOptionAction(); + const savedObjectDeleteAction = new SavedObjectDeleteAction(); + const pluginResourceDeleteAction = new PluginResourceDeleteAction(); + + uiActions.registerAction(openEventsFlyoutAction); + uiActions.registerAction(viewEventsOptionAction); + uiActions.registerAction(savedObjectDeleteAction); + uiActions.registerAction(pluginResourceDeleteAction); + + uiActions.registerTrigger(externalActionTrigger); + uiActions.registerTrigger(pluginResourceDeleteTrigger); + + uiActions.addTriggerAction(EXTERNAL_ACTION_TRIGGER, openEventsFlyoutAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, viewEventsOptionAction); + uiActions.addTriggerAction(SAVED_OBJECT_DELETE_TRIGGER, savedObjectDeleteAction); + uiActions.addTriggerAction(PLUGIN_RESOURCE_DELETE_TRIGGER, pluginResourceDeleteAction); +}; diff --git a/src/plugins/vis_augmenter/public/utils/index.ts b/src/plugins/vis_augmenter/public/utils/index.ts new file mode 100644 index 00000000000..079132ce99d --- /dev/null +++ b/src/plugins/vis_augmenter/public/utils/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './utils'; diff --git a/src/plugins/vis_augmenter/public/utils/utils.test.ts b/src/plugins/vis_augmenter/public/utils/utils.test.ts new file mode 100644 index 00000000000..06249cc088a --- /dev/null +++ b/src/plugins/vis_augmenter/public/utils/utils.test.ts @@ -0,0 +1,578 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Vis } from '../../../visualizations/public'; +import { + buildPipelineFromAugmentVisSavedObjs, + getAugmentVisSavedObjs, + getAnyErrors, + isEligibleForVisLayers, + createSavedAugmentVisLoader, + SavedObjectOpenSearchDashboardsServicesWithAugmentVis, + getMockAugmentVisSavedObjectClient, + generateAugmentVisSavedObject, + ISavedAugmentVis, + VisLayerTypes, + VisLayerExpressionFn, + cleanupStaleObjects, + VisLayer, + PluginResource, + VisLayerErrorTypes, + SavedObjectLoaderAugmentVis, +} from '../'; +import { PLUGIN_AUGMENTATION_ENABLE_SETTING } from '../../common/constants'; +import { AggConfigs } from '../../../data/common'; +import { uiSettingsServiceMock } from '../../../../core/public/mocks'; +import { setUISettings } from '../services'; +import { + STUB_INDEX_PATTERN_WITH_FIELDS, + TYPES_REGISTRY, + VALID_AGGS, + VALID_CONFIG_STATES, + VALID_VIS, + createPointInTimeEventsVisLayer, + createVisLayer, +} from '../mocks'; + +describe('utils', () => { + const uiSettingsMock = uiSettingsServiceMock.createStartContract(); + setUISettings(uiSettingsMock); + beforeEach(() => { + uiSettingsMock.get.mockImplementation((key: string) => { + return key === PLUGIN_AUGMENTATION_ENABLE_SETTING; + }); + }); + describe('isEligibleForVisLayers', () => { + it('vis is ineligible with invalid non-line type', async () => { + const vis = ({ + params: { + type: 'not-line', + seriesParams: [], + categoryAxes: [ + { + position: 'bottom', + }, + ], + }, + data: { + aggs: VALID_AGGS, + }, + } as unknown) as Vis; + expect(isEligibleForVisLayers(vis)).toEqual(false); + }); + it('vis is ineligible with no date_histogram', async () => { + const invalidConfigStates = [ + { + enabled: true, + type: 'histogram', + params: {}, + }, + { + enabled: true, + type: 'metrics', + params: {}, + }, + ]; + const invalidAggs = new AggConfigs(STUB_INDEX_PATTERN_WITH_FIELDS, invalidConfigStates, { + typesRegistry: TYPES_REGISTRY, + }); + const vis = ({ + params: { + type: 'line', + seriesParams: [], + }, + data: { + invalidAggs, + }, + } as unknown) as Vis; + expect(isEligibleForVisLayers(vis)).toEqual(false); + }); + it('vis is ineligible with invalid aggs counts', async () => { + const invalidConfigStates = [ + ...VALID_CONFIG_STATES, + { + enabled: true, + type: 'dot', + params: {}, + schema: 'radius', + }, + ]; + const invalidAggs = new AggConfigs(STUB_INDEX_PATTERN_WITH_FIELDS, invalidConfigStates, { + typesRegistry: TYPES_REGISTRY, + }); + const vis = ({ + params: { + type: 'line', + seriesParams: [], + }, + data: { + invalidAggs, + }, + } as unknown) as Vis; + expect(isEligibleForVisLayers(vis)).toEqual(false); + }); + it('vis is ineligible with no metric aggs', async () => { + const invalidConfigStates = [ + { + enabled: true, + type: 'date_histogram', + params: {}, + }, + ]; + const invalidAggs = new AggConfigs(STUB_INDEX_PATTERN_WITH_FIELDS, invalidConfigStates, { + typesRegistry: TYPES_REGISTRY, + }); + const vis = ({ + params: { + type: 'line', + seriesParams: [], + }, + data: { + invalidAggs, + }, + } as unknown) as Vis; + expect(isEligibleForVisLayers(vis)).toEqual(false); + }); + it('vis is ineligible with series param is not line type', async () => { + const vis = ({ + params: { + type: 'line', + seriesParams: [ + { + type: 'area', + }, + ], + categoryAxes: [ + { + position: 'bottom', + }, + ], + }, + data: { + aggs: VALID_AGGS, + }, + } as unknown) as Vis; + expect(isEligibleForVisLayers(vis)).toEqual(false); + }); + it('vis is ineligible with series param not all being line type', async () => { + const vis = ({ + params: { + type: 'line', + seriesParams: [ + { + type: 'area', + }, + { + type: 'line', + }, + ], + categoryAxes: [ + { + position: 'bottom', + }, + ], + }, + data: { + aggs: VALID_AGGS, + }, + } as unknown) as Vis; + expect(isEligibleForVisLayers(vis)).toEqual(false); + }); + it('vis is ineligible with invalid x-axis due to no segment aggregation', async () => { + const badConfigStates = [ + { + enabled: true, + type: 'max', + params: {}, + schema: 'metric', + }, + { + enabled: true, + type: 'max', + params: {}, + schema: 'metric', + }, + ]; + const badAggs = new AggConfigs(STUB_INDEX_PATTERN_WITH_FIELDS, badConfigStates, { + typesRegistry: TYPES_REGISTRY, + }); + const invalidVis = ({ + params: { + type: 'line', + seriesParams: [ + { + type: 'line', + }, + ], + categoryAxes: [ + { + position: 'bottom', + }, + ], + }, + data: { + badAggs, + }, + } as unknown) as Vis; + expect(isEligibleForVisLayers(invalidVis)).toEqual(false); + }); + it('vis is ineligible with xaxis not on bottom', async () => { + const invalidVis = ({ + params: { + type: 'line', + seriesParams: [ + { + type: 'line', + }, + ], + categoryAxes: [ + { + position: 'top', + }, + ], + }, + data: { + aggs: VALID_AGGS, + }, + } as unknown) as Vis; + expect(isEligibleForVisLayers(invalidVis)).toEqual(false); + }); + it('vis is ineligible with no seriesParams', async () => { + const invalidVis = ({ + params: { + type: 'pie', + categoryAxes: [ + { + position: 'bottom', + }, + ], + }, + data: { + aggs: VALID_AGGS, + }, + } as unknown) as Vis; + expect(isEligibleForVisLayers(invalidVis)).toEqual(false); + }); + it('vis is ineligible with valid type and disabled setting', async () => { + uiSettingsMock.get.mockImplementation((key: string) => { + return key !== PLUGIN_AUGMENTATION_ENABLE_SETTING; + }); + expect(isEligibleForVisLayers(VALID_VIS)).toEqual(false); + }); + it('vis is eligible with valid type', async () => { + expect(isEligibleForVisLayers(VALID_VIS)).toEqual(true); + }); + }); + + describe('getAugmentVisSavedObjs', () => { + const fn = { + type: VisLayerTypes.PointInTimeEvents, + name: 'test-fn', + args: { + testArg: 'test-value', + }, + } as VisLayerExpressionFn; + const originPlugin = 'test-plugin'; + const pluginResource = { + type: 'test-plugin', + id: 'test-plugin-resource-id', + }; + const visId1 = 'test-vis-id-1'; + const visId2 = 'test-vis-id-2'; + const visId3 = 'test-vis-id-3'; + const obj1 = generateAugmentVisSavedObject( + 'valid-obj-id-1', + fn, + visId1, + originPlugin, + pluginResource + ); + const obj2 = generateAugmentVisSavedObject( + 'valid-obj-id-2', + fn, + visId1, + originPlugin, + pluginResource + ); + const obj3 = generateAugmentVisSavedObject( + 'valid-obj-id-3', + fn, + visId2, + originPlugin, + pluginResource + ); + + it('returns no matching saved objs with filtering', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([obj1, obj2, obj3]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + expect((await getAugmentVisSavedObjs(visId3, loader)).length).toEqual(0); + }); + it('returns no matching saved objs when client returns empty list', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + expect((await getAugmentVisSavedObjs(visId1, loader)).length).toEqual(0); + }); + it('returns one matching saved obj', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([obj1]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + expect((await getAugmentVisSavedObjs(visId1, loader)).length).toEqual(1); + }); + it('returns multiple matching saved objs without filtering', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([obj1, obj2]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + expect((await getAugmentVisSavedObjs(visId1, loader)).length).toEqual(2); + }); + it('returns multiple matching saved objs with filtering', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([obj1, obj2, obj3]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + expect((await getAugmentVisSavedObjs(visId1, loader)).length).toEqual(2); + }); + }); + + describe('buildPipelineFromAugmentVisSavedObjs', () => { + const obj1 = { + title: 'obj1', + originPlugin: 'test-plugin', + pluginResource: { + type: 'test-resource-type', + id: 'obj-1-resource-id', + }, + visLayerExpressionFn: { + type: VisLayerTypes.PointInTimeEvents, + name: 'fn-1', + args: { + arg1: 'value-1', + }, + }, + } as ISavedAugmentVis; + const obj2 = { + title: 'obj2', + originPlugin: 'test-plugin', + pluginResource: { + type: 'test-resource-type', + id: 'obj-2-resource-id', + }, + visLayerExpressionFn: { + type: VisLayerTypes.PointInTimeEvents, + name: 'fn-2', + args: { + arg2: 'value-2', + }, + }, + } as ISavedAugmentVis; + it('catches error with empty array', async () => { + try { + buildPipelineFromAugmentVisSavedObjs([]); + } catch (e: any) { + expect( + e.message.includes( + 'Expression function from augment-vis saved objects could not be generated' + ) + ); + } + }); + it('builds with one saved obj', async () => { + const str = buildPipelineFromAugmentVisSavedObjs([obj1]); + expect(str).toEqual('fn-1 arg1="value-1"'); + }); + it('builds with multiple saved objs', async () => { + const str = buildPipelineFromAugmentVisSavedObjs([obj1, obj2]); + expect(str).toEqual(`fn-1 arg1="value-1"\n| fn-2 arg2="value-2"`); + }); + }); + + describe('getAnyErrors', () => { + const noErrorLayer1 = createVisLayer(VisLayerTypes.PointInTimeEvents, false); + const noErrorLayer2 = createVisLayer(VisLayerTypes.PointInTimeEvents, false); + const errorLayer1 = createVisLayer(VisLayerTypes.PointInTimeEvents, true, 'uh-oh!', { + type: 'resource-type-1', + id: '1234', + name: 'resource-1', + }); + const errorLayer2 = createVisLayer( + VisLayerTypes.PointInTimeEvents, + true, + 'oh no something terrible has happened :(', + { + type: 'resource-type-2', + id: '5678', + name: 'resource-2', + } + ); + const errorLayer3 = createVisLayer(VisLayerTypes.PointInTimeEvents, true, 'oops!', { + type: 'resource-type-1', + id: 'abcd', + name: 'resource-3', + }); + + it('empty array - returns undefined', async () => { + const err = getAnyErrors([], 'title-vis-title'); + expect(err).toEqual(undefined); + }); + it('single VisLayer no errors - returns undefined', async () => { + const err = getAnyErrors([noErrorLayer1], 'test-vis-title'); + expect(err).toEqual(undefined); + }); + it('multiple VisLayers no errors - returns undefined', async () => { + const err = getAnyErrors([noErrorLayer1, noErrorLayer2], 'test-vis-title'); + expect(err).toEqual(undefined); + }); + it('single VisLayer with error - returns formatted error', async () => { + const err = getAnyErrors([errorLayer1], 'test-vis-title'); + expect(err).not.toEqual(undefined); + expect(err?.stack).toStrictEqual(`-----resource-type-1-----\nID: 1234\nMessage: "uh-oh!"`); + }); + it('multiple VisLayers with errors - returns formatted error', async () => { + const err = getAnyErrors([errorLayer1, errorLayer2], 'test-vis-title'); + expect(err).not.toEqual(undefined); + expect(err?.stack).toStrictEqual( + `-----resource-type-1-----\nID: 1234\nMessage: "uh-oh!"\n\n\n` + + `-----resource-type-2-----\nID: 5678\nMessage: "oh no something terrible has happened :("` + ); + }); + it('multiple VisLayers with errors of same type - returns formatted error', async () => { + const err = getAnyErrors([errorLayer1, errorLayer3], 'test-vis-title'); + expect(err).not.toEqual(undefined); + expect(err?.stack).toStrictEqual( + `-----resource-type-1-----\nID: 1234\nMessage: "uh-oh!"\n\n` + `ID: abcd\nMessage: "oops!"` + ); + }); + it('VisLayers with and without error - returns formatted error', async () => { + const err = getAnyErrors([noErrorLayer1, errorLayer1], 'test-vis-title'); + expect(err).not.toEqual(undefined); + expect(err?.stack).toStrictEqual(`-----resource-type-1-----\nID: 1234\nMessage: "uh-oh!"`); + }); + }); + + describe('cleanupStaleObjects', () => { + const fn = { + type: VisLayerTypes.PointInTimeEvents, + name: 'test-fn', + args: { + testArg: 'test-value', + }, + } as VisLayerExpressionFn; + const originPlugin = 'test-plugin'; + const resourceId1 = 'resource-1'; + const resourceId2 = 'resource-2'; + const resourceType1 = 'resource-type-1'; + const augmentVisObj1 = generateAugmentVisSavedObject('id-1', fn, 'vis-id-1', originPlugin, { + type: resourceType1, + id: resourceId1, + }); + const augmentVisObj2 = generateAugmentVisSavedObject('id-2', fn, 'vis-id-1', originPlugin, { + type: resourceType1, + id: resourceId2, + }); + const resource1 = { + type: 'test-resource-type-1', + id: resourceId1, + name: 'resource-1', + urlPath: 'test-path', + } as PluginResource; + const resource2 = { + type: 'test-resource-type-1', + id: resourceId2, + name: 'resource-2', + urlPath: 'test-path', + } as PluginResource; + const validVisLayer1 = createPointInTimeEventsVisLayer(originPlugin, resource1, 1, false); + const staleVisLayer1 = { + ...createPointInTimeEventsVisLayer(originPlugin, resource1, 0, true), + error: { + type: VisLayerErrorTypes.RESOURCE_DELETED, + message: 'resource is deleted', + }, + }; + const staleVisLayer2 = { + ...createPointInTimeEventsVisLayer(originPlugin, resource2, 0, true), + error: { + type: VisLayerErrorTypes.RESOURCE_DELETED, + message: 'resource is deleted', + }, + }; + + it('no augment-vis objs, no vislayers', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [] as ISavedAugmentVis[]; + const visLayers = [] as VisLayer[]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(0); + }); + it('no stale vislayers', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [augmentVisObj1]; + const visLayers = [validVisLayer1]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(0); + }); + it('1 stale vislayer', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [augmentVisObj1]; + const visLayers = [staleVisLayer1]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(1); + }); + it('multiple stale vislayers', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [augmentVisObj1, augmentVisObj2]; + const visLayers = [staleVisLayer1, staleVisLayer2]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(2); + }); + it('stale and valid vislayers', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [augmentVisObj1, augmentVisObj2]; + const visLayers = [validVisLayer1, staleVisLayer2]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/plugins/vis_augmenter/public/utils/utils.ts b/src/plugins/vis_augmenter/public/utils/utils.ts new file mode 100644 index 00000000000..77aa14a204c --- /dev/null +++ b/src/plugins/vis_augmenter/public/utils/utils.ts @@ -0,0 +1,182 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get, isEmpty } from 'lodash'; +import { Vis } from '../../../../plugins/visualizations/public'; +import { + formatExpression, + buildExpressionFunction, + buildExpression, + ExpressionAstFunctionBuilder, +} from '../../../../plugins/expressions/public'; +import { + ISavedAugmentVis, + SavedAugmentVisLoader, + VisLayerFunctionDefinition, + VisLayer, + isVisLayerWithError, + VisLayerErrorTypes, +} from '../'; +import { PLUGIN_AUGMENTATION_ENABLE_SETTING } from '../../common/constants'; +import { getUISettings } from '../services'; +import { IUiSettingsClient } from '../../../../core/public'; + +export const isEligibleForVisLayers = (vis: Vis, uiSettingsClient?: IUiSettingsClient): boolean => { + // Only support date histogram and ensure there is only 1 x-axis and it has to be on the bottom. + // Additionally to have a valid x-axis, there needs to be a segment aggregation + const hasValidXaxis = + vis.data?.aggs !== undefined && + vis.data.aggs?.byTypeName('date_histogram').length === 1 && + vis.params.categoryAxes.length === 1 && + vis.params.categoryAxes[0].position === 'bottom' && + vis.data.aggs?.bySchemaName('segment').length > 0; + // Support 1 segment for x axis bucket (that is date_histogram) and support metrics for + // multiple supported yaxis only. If there are other aggregation types, this is not + // valid for augmentation + const hasCorrectAggregationCount = + vis.data?.aggs !== undefined && + vis.data.aggs?.bySchemaName('metric').length > 0 && + vis.data.aggs?.bySchemaName('metric').length === vis.data.aggs?.aggs.length - 1; + const hasOnlyLineSeries = + vis.params?.seriesParams !== undefined && + vis.params?.seriesParams?.every( + (seriesParam: { type: string }) => seriesParam.type === 'line' + ) && + vis.params?.type === 'line'; + + // Checks if the augmentation setting is enabled + const config = uiSettingsClient ?? getUISettings(); + const isAugmentationEnabled = config.get(PLUGIN_AUGMENTATION_ENABLE_SETTING); + return isAugmentationEnabled && hasValidXaxis && hasCorrectAggregationCount && hasOnlyLineSeries; +}; + +/** + * Using a SavedAugmentVisLoader, fetch all saved objects that are of 'augment-vis' type. + * Filter by vis ID. + */ +export const getAugmentVisSavedObjs = async ( + visId: string | undefined, + loader: SavedAugmentVisLoader | undefined, + uiSettings?: IUiSettingsClient | undefined +): Promise => { + // Using optional services provided, or the built-in services from this plugin + const config = uiSettings !== undefined ? uiSettings : getUISettings(); + const isAugmentationEnabled = config.get(PLUGIN_AUGMENTATION_ENABLE_SETTING); + if (!isAugmentationEnabled) { + throw new Error( + 'Visualization augmentation is disabled, please enable visualization:enablePluginAugmentation.' + ); + } + try { + const allSavedObjects = await getAllAugmentVisSavedObjs(loader); + return allSavedObjects.filter((hit: ISavedAugmentVis) => hit.visId === visId); + } catch (e) { + return [] as ISavedAugmentVis[]; + } +}; + +/** + * Using a SavedAugmentVisLoader, fetch all saved objects that are of 'augment-vis' type. + */ +export const getAllAugmentVisSavedObjs = async ( + loader: SavedAugmentVisLoader | undefined +): Promise => { + try { + const resp = await loader?.findAll(); + return (get(resp, 'hits', []) as any[]) as ISavedAugmentVis[]; + } catch (e) { + return [] as ISavedAugmentVis[]; + } +}; + +/** + * Given an array of augment-vis saved objects that contain expression function details, + * construct a pipeline that will execute each of these expression functions. + * Note that the order does not matter; each expression function should be taking + * in the current output and appending its results to it, such that the end result + * contains the results from each expression function that was ran. + */ +export const buildPipelineFromAugmentVisSavedObjs = (objs: ISavedAugmentVis[]): string => { + try { + const visLayerExpressionFns = objs.map((obj: ISavedAugmentVis) => + buildExpressionFunction( + obj.visLayerExpressionFn.name, + obj.visLayerExpressionFn.args + ) + ) as Array>; + const ast = buildExpression(visLayerExpressionFns).toAst(); + return formatExpression(ast); + } catch (e) { + throw new Error('Expression function from augment-vis saved objects could not be generated'); + } +}; + +/** + * Returns an error with an aggregated message about all of the + * errors found in the set of VisLayers. If no errors, returns undefined. + */ +export const getAnyErrors = (visLayers: VisLayer[], visTitle: string): Error | undefined => { + const visLayersWithErrors = visLayers.filter((visLayer) => isVisLayerWithError(visLayer)); + if (!isEmpty(visLayersWithErrors)) { + // Aggregate by unique plugin resource type + const resourceTypes = [ + ...new Set(visLayersWithErrors.map((visLayer) => visLayer.pluginResource.type)), + ]; + + let msgDetails = ''; + resourceTypes.forEach((type, index) => { + const matchingVisLayers = visLayersWithErrors.filter( + (visLayer) => visLayer.pluginResource.type === type + ); + if (index !== 0) msgDetails += '\n\n\n'; + msgDetails += `-----${type}-----`; + matchingVisLayers.forEach((visLayer, idx) => { + if (idx !== 0) msgDetails += '\n'; + msgDetails += `\nID: ${visLayer.pluginResource.id}`; + msgDetails += `\nMessage: "${visLayer.error?.message}"`; + }); + }); + + const err = new Error(`Certain plugin resources failed to load on the ${visTitle} chart`); + // We set as the stack here so it can be parsed and shown cleanly in the details modal coming from the error toast notification. + err.stack = msgDetails; + return err; + } else { + return undefined; + } +}; + +/** + * Cleans up any stale saved objects caused by plugin resources being deleted. Kicks + * off an async call to delete the stale objs. + * + * @param augmentVisSavedObs the original augment-vis saved objs for this particular vis + * @param visLayers the produced VisLayers containing details if the resource has been deleted + * @param visualizationsLoader the visualizations saved object loader to handle deletion + */ + +export const cleanupStaleObjects = ( + augmentVisSavedObjs: ISavedAugmentVis[], + visLayers: VisLayer[], + loader: SavedAugmentVisLoader | undefined +): void => { + const staleVisLayers = visLayers + .filter((visLayer) => isVisLayerWithError(visLayer)) + .filter( + (visLayerWithError) => visLayerWithError.error?.type === VisLayerErrorTypes.RESOURCE_DELETED + ); + if (!isEmpty(staleVisLayers)) { + const objIdsToDelete = [] as string[]; + staleVisLayers.forEach((staleVisLayer) => { + // Match the VisLayer to its origin saved obj to extract the to-be-deleted saved obj ID + const deletedPluginResourceId = staleVisLayer.pluginResource.id; + const savedObjId = augmentVisSavedObjs.find( + (savedObj) => savedObj.pluginResource.id === deletedPluginResourceId + )?.id; + if (savedObjId !== undefined) objIdsToDelete.push(savedObjId); + }); + loader?.delete(objIdsToDelete); + } +}; diff --git a/src/plugins/vis_augmenter/public/vega/README.md b/src/plugins/vis_augmenter/public/vega/README.md new file mode 100644 index 00000000000..fef45af1777 --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/README.md @@ -0,0 +1 @@ +Contains the helper functions that are optionally used when rendering vega charts that are eligible for rendering with VisLayers. diff --git a/src/plugins/vis_augmenter/public/vega/constants.ts b/src/plugins/vis_augmenter/public/vega/constants.ts new file mode 100644 index 00000000000..a5729725422 --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum VisAnnotationType { + POINT_IN_TIME_ANNOTATION = 'POINT_IN_TIME_ANNOTATION', +} diff --git a/src/plugins/vis_augmenter/public/vega/helpers.test.ts b/src/plugins/vis_augmenter/public/vega/helpers.test.ts new file mode 100644 index 00000000000..ce5c68075af --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/helpers.test.ts @@ -0,0 +1,463 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cloneDeep } from 'lodash'; +import { + OpenSearchDashboardsDatatable, + OpenSearchDashboardsDatatableColumn, +} from '../../../expressions/public'; +import { + enableVisLayersInSpecConfig, + isVisLayerColumn, + generateVisLayerFilterString, + addMissingRowsToTableBounds, + addPointInTimeEventsLayersToTable, + addPointInTimeEventsLayersToSpec, + generateVisLayerTooltipFields, +} from './helpers'; +import { VIS_LAYER_COLUMN_TYPE, VisLayerTypes, PointInTimeEventsVisLayer, VisLayer } from '../'; +import { + TEST_DATATABLE_MULTIPLE_VIS_LAYERS, + TEST_DATATABLE_NO_VIS_LAYERS, + TEST_DATATABLE_ONLY_VIS_LAYERS, + TEST_DATATABLE_SINGLE_ROW_NO_VIS_LAYERS, + TEST_DATATABLE_SINGLE_ROW_SINGLE_VIS_LAYER, + TEST_DATATABLE_SINGLE_VIS_LAYER, + TEST_DATATABLE_SINGLE_VIS_LAYER_EMPTY, + TEST_DATATABLE_SINGLE_VIS_LAYER_ON_BOUNDS, + TEST_DIMENSIONS, + TEST_DIMENSIONS_INVALID_BOUNDS, + TEST_DIMENSIONS_SINGLE_ROW, + TEST_RESULT_SPEC_MULTIPLE_VIS_LAYERS, + TEST_RESULT_SPEC_SINGLE_VIS_LAYER, + TEST_RESULT_SPEC_SINGLE_VIS_LAYER_EMPTY, + TEST_SPEC_MULTIPLE_VIS_LAYERS, + TEST_SPEC_NO_VIS_LAYERS, + TEST_SPEC_SINGLE_VIS_LAYER, + TEST_VIS_LAYERS_MULTIPLE, + TEST_VIS_LAYERS_SINGLE, + TEST_VIS_LAYERS_SINGLE_INVALID_BOUNDS, + TEST_VIS_LAYERS_SINGLE_ON_BOUNDS, +} from '../test_constants'; + +describe('helpers', function () { + describe('enableVisLayersInSpecConfig()', function () { + const pointInTimeEventsVisLayer = { + type: VisLayerTypes.PointInTimeEvents, + originPlugin: 'test-plugin', + pluginResource: { + type: 'test-resource-type', + id: 'test-resource-id', + name: 'test-resource-name', + urlPath: 'test-resource-url-path', + }, + events: [ + { + timestamp: 1234, + metadata: { + pluginResourceId: 'test-resource-id', + }, + }, + ], + } as PointInTimeEventsVisLayer; + const invalidVisLayer = ({ + type: 'something-invalid', + originPlugin: 'test-plugin', + pluginResource: { + type: 'test-resource-type', + id: 'test-resource-id', + name: 'test-resource-name', + urlPath: 'test-resource-url-path', + }, + } as unknown) as VisLayer; + + it('updates config with just a valid Vislayer', function () { + const baseConfig = { + kibana: { + hideWarnings: true, + }, + }; + const updatedConfig = enableVisLayersInSpecConfig({ config: baseConfig }, [ + pointInTimeEventsVisLayer, + ]); + const expectedArr = [ + ...new Map([[VisLayerTypes.PointInTimeEvents, true]]), + ]; + // @ts-ignore + baseConfig.kibana.visibleVisLayers = expectedArr; + expect(updatedConfig).toStrictEqual(baseConfig); + }); + it('updates config with a valid and invalid VisLayer', function () { + const baseConfig = { + kibana: { + hideWarnings: true, + }, + }; + const updatedConfig = enableVisLayersInSpecConfig({ config: baseConfig }, [ + pointInTimeEventsVisLayer, + invalidVisLayer, + ]); + const expectedArr = [ + ...new Map([[VisLayerTypes.PointInTimeEvents, true]]), + ]; + // @ts-ignore + baseConfig.kibana.visibleVisLayers = expectedArr; + expect(updatedConfig).toStrictEqual(baseConfig); + }); + it('does not update config if no valid VisLayer', function () { + const baseConfig = { + kibana: { + hideWarnings: true, + }, + }; + const updatedConfig = enableVisLayersInSpecConfig({ config: baseConfig }, [invalidVisLayer]); + // @ts-ignore + baseConfig.kibana.visibleVisLayers = [...new Map()]; + expect(updatedConfig).toStrictEqual(baseConfig); + }); + it('does not update config if empty VisLayer list', function () { + const baseConfig = { + kibana: { + hideWarnings: true, + }, + }; + const updatedConfig = enableVisLayersInSpecConfig({ config: baseConfig }, []); + // @ts-ignore + baseConfig.kibana.visibleVisLayers = [...new Map()]; + expect(updatedConfig).toStrictEqual(baseConfig); + }); + }); + + describe('isVisLayerColumn()', function () { + it('return false for column with invalid type', function () { + const column = { + id: 'test-id', + name: 'test-name', + meta: { + type: 'invalid-type', + }, + } as OpenSearchDashboardsDatatableColumn; + expect(isVisLayerColumn(column)).toBe(false); + }); + it('return false for column with no meta field', function () { + const column = { + id: 'test-id', + name: 'test-name', + } as OpenSearchDashboardsDatatableColumn; + expect(isVisLayerColumn(column)).toBe(false); + }); + it('return true for column with valid type', function () { + const column = { + id: 'test-id', + name: 'test-name', + meta: { + type: VIS_LAYER_COLUMN_TYPE, + }, + } as OpenSearchDashboardsDatatableColumn; + expect(isVisLayerColumn(column)).toBe(true); + }); + }); + + describe('generateVisLayerFilterString()', function () { + it('empty array returns false', function () { + const visLayerColumnIds = [] as string[]; + const filterString = 'false'; + expect(generateVisLayerFilterString(visLayerColumnIds)).toStrictEqual(filterString); + }); + it('array with one value returns correct filter string', function () { + const visLayerColumnIds = ['test-id-1']; + const filterString = `datum['test-id-1'] > 0`; + expect(generateVisLayerFilterString(visLayerColumnIds)).toStrictEqual(filterString); + }); + it('array with multiple values returns correct filter string', function () { + const visLayerColumnIds = ['test-id-1', 'test-id-2']; + const filterString = `datum['test-id-1'] > 0 || datum['test-id-2'] > 0`; + expect(generateVisLayerFilterString(visLayerColumnIds)).toStrictEqual(filterString); + }); + }); + + describe('generateVisLayerTooltipFields()', function () { + it('empty array returns empty', function () { + const visLayerColumnIds = [] as string[]; + const tooltipFields = [] as Array<{ field: string }>; + expect(generateVisLayerTooltipFields(visLayerColumnIds)).toStrictEqual(tooltipFields); + }); + it('array with one value returns correct array', function () { + const visLayerColumnIds = ['test-id-1']; + const tooltipFields = [{ field: 'test-id-1' }]; + expect(generateVisLayerTooltipFields(visLayerColumnIds)).toStrictEqual(tooltipFields); + }); + it('array with multiple values returns correct array', function () { + const visLayerColumnIds = ['test-id-1', 'test-id-2']; + const tooltipFields = [{ field: 'test-id-1' }, { field: 'test-id-2' }]; + expect(generateVisLayerTooltipFields(visLayerColumnIds)).toStrictEqual(tooltipFields); + }); + }); + + describe('addMissingRowsToTableBounds()', function () { + const columnId = 'test-id'; + const columnName = 'test-name'; + const allRows = [ + { + [columnId]: 1, + }, + { + [columnId]: 2, + }, + { + [columnId]: 3, + }, + { + [columnId]: 4, + }, + { + [columnId]: 5, + }, + ]; + it('adds single row if start/end times are the same', function () { + const datatable = { + type: 'opensearch_dashboards_datatable', + columns: [ + { + id: columnId, + name: columnName, + }, + ], + rows: [], + } as OpenSearchDashboardsDatatable; + const dimensions = { + x: { + params: { + interval: 1, + bounds: { + min: 1, + max: 1, + }, + }, + label: columnName, + }, + }; + const result = addMissingRowsToTableBounds(datatable, dimensions); + const expectedTable = { + ...datatable, + rows: [allRows[0]], + }; + expect(result).toStrictEqual(expectedTable); + }); + it('adds all rows if there is none to begin with', function () { + const datatable = { + type: 'opensearch_dashboards_datatable', + columns: [ + { + id: columnId, + name: columnName, + }, + ], + rows: [], + } as OpenSearchDashboardsDatatable; + const dimensions = { + x: { + params: { + interval: 1, + bounds: { + min: 1, + max: 5, + }, + }, + label: columnName, + }, + }; + const result = addMissingRowsToTableBounds(datatable, dimensions); + const expectedTable = { + ...datatable, + rows: allRows, + }; + expect(result).toStrictEqual(expectedTable); + }); + it('fill rows at beginning', function () { + const missingRows = cloneDeep(allRows); + missingRows.shift(); + missingRows.shift(); + const datatable = { + type: 'opensearch_dashboards_datatable', + columns: [ + { + id: columnId, + name: columnName, + }, + ], + rows: missingRows, + } as OpenSearchDashboardsDatatable; + const dimensions = { + x: { + params: { + interval: 1, + bounds: { + min: 1, + max: 5, + }, + }, + label: columnName, + }, + }; + const result = addMissingRowsToTableBounds(datatable, dimensions); + const expectedTable = { + ...datatable, + rows: allRows, + }; + expect(result).toStrictEqual(expectedTable); + }); + it('fill rows at end', function () { + const missingRows = cloneDeep(allRows); + missingRows.pop(); + missingRows.pop(); + const datatable = { + type: 'opensearch_dashboards_datatable', + columns: [ + { + id: columnId, + name: columnName, + }, + ], + rows: missingRows, + } as OpenSearchDashboardsDatatable; + const dimensions = { + x: { + params: { + interval: 1, + bounds: { + min: 1, + max: 5, + }, + }, + label: columnName, + }, + }; + const result = addMissingRowsToTableBounds(datatable, dimensions); + const expectedTable = { + ...datatable, + rows: allRows, + }; + expect(result).toStrictEqual(expectedTable); + }); + }); + + describe('addPointInTimeEventsLayersToTable()', function () { + it('single vis layer is added correctly', function () { + expect( + addPointInTimeEventsLayersToTable( + TEST_DATATABLE_NO_VIS_LAYERS, + TEST_DIMENSIONS, + TEST_VIS_LAYERS_SINGLE + ) + ).toStrictEqual(TEST_DATATABLE_SINGLE_VIS_LAYER); + }); + it('multiple vis layers are added correctly', function () { + expect( + addPointInTimeEventsLayersToTable( + TEST_DATATABLE_NO_VIS_LAYERS, + TEST_DIMENSIONS, + TEST_VIS_LAYERS_MULTIPLE + ) + ).toStrictEqual(TEST_DATATABLE_MULTIPLE_VIS_LAYERS); + }); + it('invalid bounds adds no row data', function () { + expect( + addPointInTimeEventsLayersToTable( + { + ...TEST_DATATABLE_NO_VIS_LAYERS, + rows: [], + }, + TEST_DIMENSIONS_INVALID_BOUNDS, + TEST_VIS_LAYERS_SINGLE + ) + ).toStrictEqual({ + ...TEST_DATATABLE_NO_VIS_LAYERS, + rows: [], + }); + }); + it('vis layers with single row are added correctly', function () { + expect( + addPointInTimeEventsLayersToTable( + TEST_DATATABLE_SINGLE_ROW_NO_VIS_LAYERS, + TEST_DIMENSIONS_SINGLE_ROW, + TEST_VIS_LAYERS_SINGLE + ) + ).toStrictEqual(TEST_DATATABLE_SINGLE_ROW_SINGLE_VIS_LAYER); + }); + it('vis layers with no existing rows/data are added correctly', function () { + expect( + addPointInTimeEventsLayersToTable( + { + ...TEST_DATATABLE_NO_VIS_LAYERS, + rows: [], + }, + TEST_DIMENSIONS, + TEST_VIS_LAYERS_SINGLE + ) + ).toStrictEqual(TEST_DATATABLE_ONLY_VIS_LAYERS); + }); + it('vis layer with out-of-bounds timestamps are not added', function () { + expect( + addPointInTimeEventsLayersToTable( + TEST_DATATABLE_NO_VIS_LAYERS, + TEST_DIMENSIONS, + TEST_VIS_LAYERS_SINGLE_INVALID_BOUNDS + ) + ).toStrictEqual(TEST_DATATABLE_SINGLE_VIS_LAYER_EMPTY); + }); + it('vis layer with events on edge of bounds are added', function () { + expect( + addPointInTimeEventsLayersToTable( + TEST_DATATABLE_NO_VIS_LAYERS, + TEST_DIMENSIONS, + TEST_VIS_LAYERS_SINGLE_ON_BOUNDS + ) + ).toStrictEqual(TEST_DATATABLE_SINGLE_VIS_LAYER_ON_BOUNDS); + }); + }); + + describe('addPointInTimeEventsLayersToSpec()', function () { + it('spec with single time series produces correct spec', function () { + const expectedSpec = TEST_RESULT_SPEC_SINGLE_VIS_LAYER; + const returnSpec = addPointInTimeEventsLayersToSpec( + TEST_DATATABLE_SINGLE_VIS_LAYER, + TEST_DIMENSIONS, + TEST_SPEC_SINGLE_VIS_LAYER + ); + // deleting the scale fields since this contain generated + // fields based on timezone env it is run in + delete expectedSpec.vconcat[1].encoding.x.scale; + delete returnSpec.vconcat[1].encoding.x.scale; + expect(returnSpec).toEqual(expectedSpec); + }); + it('spec with multiple time series produces correct spec', function () { + const expectedSpec = TEST_RESULT_SPEC_MULTIPLE_VIS_LAYERS; + const returnSpec = addPointInTimeEventsLayersToSpec( + TEST_DATATABLE_MULTIPLE_VIS_LAYERS, + TEST_DIMENSIONS, + TEST_SPEC_MULTIPLE_VIS_LAYERS + ); + // deleting the scale fields since this contain generated + // fields based on timezone env it is run in + delete expectedSpec.vconcat[1].encoding.x.scale; + delete returnSpec.vconcat[1].encoding.x.scale; + expect(returnSpec).toEqual(expectedSpec); + }); + it('spec with vis layers with empty data produces correct spec', function () { + const expectedSpec = TEST_RESULT_SPEC_SINGLE_VIS_LAYER_EMPTY; + const returnSpec = addPointInTimeEventsLayersToSpec( + TEST_DATATABLE_SINGLE_VIS_LAYER_EMPTY, + TEST_DIMENSIONS, + TEST_SPEC_NO_VIS_LAYERS + ); + // deleting the scale fields since this contain generated + // fields based on timezone env it is run in + delete expectedSpec.vconcat[1].encoding.x.scale; + delete returnSpec.vconcat[1].encoding.x.scale; + expect(returnSpec).toEqual(expectedSpec); + }); + }); +}); diff --git a/src/plugins/vis_augmenter/public/vega/helpers.ts b/src/plugins/vis_augmenter/public/vega/helpers.ts new file mode 100644 index 00000000000..846a40adf33 --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/helpers.ts @@ -0,0 +1,509 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import moment from 'moment'; +import { cloneDeep, isEmpty, get } from 'lodash'; +import { Item } from 'vega'; +import { YAxisConfig } from 'src/plugins/vis_type_vega/public'; +import { + OpenSearchDashboardsDatatable, + OpenSearchDashboardsDatatableColumn, +} from '../../../expressions/public'; +import { + PointInTimeEvent, + PointInTimeEventsVisLayer, + isPointInTimeEventsVisLayer, + VIS_LAYER_COLUMN_TYPE, + EVENT_COLOR, + EVENT_MARK_SIZE, + EVENT_MARK_SIZE_ENLARGED, + EVENT_MARK_SHAPE, + EVENT_TIMELINE_HEIGHT, + EVENT_TOOLTIP_CENTER_ON_MARK, + HOVER_PARAM, + VisLayer, + VisLayers, + VisLayerTypes, + VisAugmenterEmbeddableConfig, + VisFlyoutContext, +} 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. +export const enableVisLayersInSpecConfig = (spec: object, visLayers: VisLayers): {} => { + const config = get(spec, 'config', { kibana: {} }); + const visibleVisLayers = new Map(); + + // Currently only support PointInTimeEventsVisLayers. Set the flag to true + // if there are any + const pointInTimeEventsVisLayers = visLayers.filter((visLayer: VisLayer) => + isPointInTimeEventsVisLayer(visLayer) + ) as PointInTimeEventsVisLayer[]; + if (!isEmpty(pointInTimeEventsVisLayers)) { + visibleVisLayers.set(VisLayerTypes.PointInTimeEvents, true); + } + return { + ...config, + kibana: { + ...config.kibana, + visibleVisLayers: [...visibleVisLayers], + }, + }; +}; + +/** + * 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}`]: [ + { + name: 'PointInTimeAnnotationVisEvent', + on: [{ events: 'click', update: 'opensearchDashboardsVisEventTriggered(event, datum)' }], + }, + ], + }; + + return { + ...config, + kibana: { + ...config.kibana, + signals, + tooltips: { + centerOnMark: EVENT_TOOLTIP_CENTER_ON_MARK, + }, + }, + }; +}; + +// 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 = ( + dimensions: any, + columns: OpenSearchDashboardsDatatableColumn[] +): string => { + return columns.filter((column) => column.name === dimensions.x.label)[0].id; +}; + +export const isVisLayerColumn = (column: OpenSearchDashboardsDatatableColumn): boolean => { + return column.meta?.type === VIS_LAYER_COLUMN_TYPE; +}; + +/** + * For temporal domain ranges, there is a bug when passing timestamps in vega lite + * that is still present in the current libraries we are using when developing in a + * dev env. See https://github.com/vega/vega-lite/issues/6060 for bug details. + * So, we convert to a vega-lite Date Time object and pass that instead. + * See https://vega.github.io/vega-lite/docs/datetime.html for details on Date Time. + */ +const convertToDateTimeObj = (timestamp: number): any => { + const momentObj = moment(timestamp); + return { + year: Number(momentObj.format('YYYY')), + month: momentObj.format('MMMM'), + date: momentObj.date(), + hours: momentObj.hours(), + minutes: momentObj.minutes(), + seconds: momentObj.seconds(), + milliseconds: momentObj.milliseconds(), + }; +}; + +export const generateVisLayerFilterString = (visLayerColumnIds: string[]): string => { + if (!isEmpty(visLayerColumnIds)) { + const filterString = visLayerColumnIds.map( + (visLayerColumnId) => `datum['${visLayerColumnId}'] > 0` + ); + return filterString.join(' || '); + } else { + // if there is no VisLayers to display, then filter out everything by always returning false + return 'false'; + } +}; + +export const generateVisLayerTooltipFields = ( + visLayerColumnIds: string[] +): Array<{ field: string }> => { + return visLayerColumnIds.map((id) => { + return { + field: id, + }; + }); +}; + +/** + * By default, the source datatable will not include rows with empty data. + * For handling events that may belong in missing buckets that are not yet + * created, we need to create them. For more details, see description in + * https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3145 + * + * Note that this may add buckets with start/end times out of the chart bounds. + * This is the current default behavior of histogram aggregations with intervals, + * in order for the bucket keys to have "clean" timestamp keys (e.g., 1/1 @ 12AM). + * For more details, see + * https://opensearch.org/docs/latest/opensearch/bucket-agg/#histogram-date_histogram + * + * Also note this is only adding empty buckets at the beginning/end of a table. We are + * not taking into account missing buckets within source datapoints. Because of this + * limitation, it is possible that charted events may not be put into the most precise + * bucket based on their raw event timestamps, if there is missing / sparse source data. + */ +export const addMissingRowsToTableBounds = ( + datatable: OpenSearchDashboardsDatatable, + dimensions: any +): OpenSearchDashboardsDatatable => { + const augmentedTable = cloneDeep(datatable); + const intervalMillis = moment.duration(dimensions.x.params.interval).asMilliseconds(); + const xAxisId = getXAxisId(dimensions, augmentedTable.columns); + const chartStartTime = new Date(dimensions.x.params.bounds.min).valueOf(); + const chartEndTime = new Date(dimensions.x.params.bounds.max).valueOf(); + + if (!isEmpty(augmentedTable.rows)) { + const dataStartTime = augmentedTable.rows[0][xAxisId] as number; + const dataEndTime = augmentedTable.rows[augmentedTable.rows.length - 1][xAxisId] as number; + + let curStartTime = dataStartTime; + while (curStartTime > chartStartTime) { + curStartTime -= intervalMillis; + augmentedTable.rows.unshift({ + [xAxisId]: curStartTime, + }); + } + + let curEndTime = dataEndTime; + while (curEndTime < chartEndTime) { + curEndTime += intervalMillis; + augmentedTable.rows.push({ + [xAxisId]: curEndTime, + }); + } + } else { + // if there's no existing rows, create them all + let curTime = chartStartTime; + while (curTime <= chartEndTime) { + augmentedTable.rows.push({ + [xAxisId]: curTime, + }); + curTime += intervalMillis; + } + } + return augmentedTable; +}; + +/** + * Adding events into the correct x-axis key (the time bucket) + * based on the table. As of now only results from + * PointInTimeEventsVisLayers are supported + */ +export const addPointInTimeEventsLayersToTable = ( + datatable: OpenSearchDashboardsDatatable, + dimensions: any, + visLayers: PointInTimeEventsVisLayer[] +): OpenSearchDashboardsDatatable => { + const augmentedTable = addMissingRowsToTableBounds(datatable, dimensions); + const xAxisId = getXAxisId(dimensions, augmentedTable.columns); + + if (isEmpty(visLayers) || augmentedTable.rows.length === 0) return augmentedTable; + + // Create columns for every unique event type. This is so we can aggregate on the different event types + // (e.g., 'Anomalies', 'Alerts') + [ + ...new Set(visLayers.map((visLayer: PointInTimeEventsVisLayer) => visLayer.pluginEventType)), + ].forEach((pluginEventType: string) => { + augmentedTable.columns.push({ + id: pluginEventType, + name: `${pluginEventType} count`, + meta: { + type: VIS_LAYER_COLUMN_TYPE, + }, + }); + }); + + visLayers.forEach((visLayer: PointInTimeEventsVisLayer) => { + if (isEmpty(visLayer.events)) return; + const visLayerColumnId = `${visLayer.pluginEventType}`; + + // Add placeholder values of 0 for every event value. This is so the tooltip + // can render correctly without showing the 'undefined' string + let row = 0; + while (row < augmentedTable.rows.length) { + augmentedTable.rows[row] = { + ...augmentedTable.rows[row], + [visLayerColumnId]: get(augmentedTable.rows[row], visLayerColumnId, 0) as number, + }; + row++; + } + + // if only one row / one datapoint, put all events into this bucket + if (augmentedTable.rows.length === 1) { + augmentedTable.rows[0] = { + ...augmentedTable.rows[0], + [visLayerColumnId]: + (get(augmentedTable.rows[0], visLayerColumnId, 0) as number) + visLayer.events.length, + }; + return; + } + + // Bin the timestamps to the closest x-axis key, adding + // an entry for this vis layer ID. Sorting the timestamps first + // so that we will only search a particular row value once. + // There could be some optimizations, such as binary search + dynamically + // changing the bounds, but performance benefits would be very minimal + // if any, given the upper bounds limit on n already due to chart constraints. + let rowIndex = 0; + const minVal = augmentedTable.rows[0][xAxisId] as number; + const maxVal = + (augmentedTable.rows[augmentedTable.rows.length - 1][xAxisId] as number) + + moment.duration(dimensions.x.params.interval).asMilliseconds(); + const sortedTimestamps = visLayer.events + .map((event: PointInTimeEvent) => event.timestamp) + .filter((timestamp: number) => timestamp >= minVal && timestamp <= maxVal) + .sort((n1: number, n2: number) => n1 - n2) as number[]; + + sortedTimestamps.forEach((timestamp) => { + while (rowIndex < augmentedTable.rows.length - 1) { + const smallerVal = augmentedTable.rows[rowIndex][xAxisId] as number; + const higherVal = augmentedTable.rows[rowIndex + 1][xAxisId] as number; + let rowIndexToInsert: number; + + // timestamp is on the left bounds of the chart + if (timestamp === smallerVal) { + rowIndexToInsert = rowIndex; + + // timestamp is in between the right 2 buckets. determine which one it is closer to + } else if (timestamp <= higherVal) { + const smallerValDiff = Math.abs(timestamp - smallerVal); + const higherValDiff = Math.abs(timestamp - higherVal); + rowIndexToInsert = smallerValDiff <= higherValDiff ? rowIndex : rowIndex + 1; + } + + // timestamp is on the right bounds of the chart + else if (rowIndex + 1 === augmentedTable.rows.length - 1) { + rowIndexToInsert = rowIndex + 1; + // timestamp is still too small; traverse to next bucket + } else { + rowIndex += 1; + continue; + } + + // inserting the value. increment if the mapping/property already exists + augmentedTable.rows[rowIndexToInsert][visLayerColumnId] = + (get(augmentedTable.rows[rowIndexToInsert], visLayerColumnId, 0) as number) + 1; + break; + } + }); + }); + return augmentedTable; +}; + +/** + * Updating the vega lite spec to include layers and marks related to + * PointInTimeEventsVisLayers. It is assumed the datatable has already been + * augmented with columns and row data containing the vis layers. + */ +export const addPointInTimeEventsLayersToSpec = ( + datatable: OpenSearchDashboardsDatatable, + dimensions: any, + spec: object +): object => { + const newSpec = cloneDeep(spec) as any; + + const xAxisId = getXAxisId(dimensions, datatable.columns); + const xAxisTitle = dimensions.x.label.replaceAll('"', ''); + const bucketStartTime = convertToDateTimeObj(datatable.rows[0][xAxisId] as number); + const bucketEndTime = convertToDateTimeObj( + datatable.rows[datatable.rows.length - 1][xAxisId] as number + ); + const visLayerColumns = datatable.columns.filter((column: OpenSearchDashboardsDatatableColumn) => + isVisLayerColumn(column) + ); + const visLayerColumnIds = visLayerColumns.map((column) => column.id); + + // Hide x axes text on existing chart so they are only visible on the event chart + newSpec.layer.forEach((dataSeries: any) => { + if (get(dataSeries, 'encoding.x.axis', null) !== null) { + dataSeries.encoding.x.axis = { + ...dataSeries.encoding.x.axis, + labels: false, + title: null, + }; + } + }); + + // Add a rule to the existing layer for showing lines on the chart if a dot is hovered on + newSpec.layer.push({ + mark: { + type: 'rule', + color: EVENT_COLOR, + opacity: 1, + }, + transform: [{ filter: generateVisLayerFilterString(visLayerColumnIds) }], + encoding: { + x: { + field: xAxisId, + type: 'temporal', + }, + opacity: { + value: 0, + condition: { empty: false, param: HOVER_PARAM, value: 1 }, + }, + }, + }); + + // Nesting layer into a vconcat field so we can append event chart. + newSpec.vconcat = [] as any[]; + newSpec.vconcat.push({ + layer: newSpec.layer, + }); + delete newSpec.layer; + + // Adding the event timeline chart + newSpec.vconcat.push({ + height: EVENT_TIMELINE_HEIGHT, + mark: { + type: 'point', + shape: EVENT_MARK_SHAPE, + fill: EVENT_COLOR, + stroke: EVENT_COLOR, + strokeOpacity: 1, + fillOpacity: 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}`], + tooltip: true, + }, + transform: [ + { filter: generateVisLayerFilterString(visLayerColumnIds) }, + { calculate: `'${VisAnnotationType.POINT_IN_TIME_ANNOTATION}'`, as: 'annotationType' }, + ], + params: [{ name: HOVER_PARAM, select: { type: 'point', on: 'mouseover' } }], + encoding: { + x: { + axis: { + title: xAxisTitle, + grid: false, + ticks: true, + orient: 'bottom', + domain: true, + }, + field: xAxisId, + type: 'temporal', + scale: { + domain: [bucketStartTime, bucketEndTime], + }, + }, + size: { + condition: { empty: false, param: HOVER_PARAM, value: EVENT_MARK_SIZE_ENLARGED }, + value: EVENT_MARK_SIZE, + }, + tooltip: generateVisLayerTooltipFields(visLayerColumnIds), + }, + }); + + return newSpec; +}; + +export const isPointInTimeAnnotation = (item?: Item | null) => { + return item?.datum?.annotationType === VisAnnotationType.POINT_IN_TIME_ANNOTATION; +}; + +// This is the total y-axis padding such that if this is added to the "padding" value of the view, if there is no axis, +// it will align values on the x-axis +export const calculateYAxisPadding = (config: YAxisConfig): number => { + // TODO: figure out where this value is coming from + const defaultPadding = 3; + return ( + get(config, 'minExtent', 0) + + get(config, 'offset', 0) + + get(config, 'translate', 0) + + get(config, 'domainWidth', 0) + + get(config, 'labelPadding', 0) + + get(config, 'titlePadding', 0) + + get(config, 'tickOffset', 0) + + get(config, 'tickSize', 0) + + defaultPadding + ); +}; + +// Parse the vis augmenter config to apply different visual changes to the event chart spec. +// This includes potentially removing the original vis data, hiding axes, moving the legend, etc. +// Primarily used within the view events flyout to render the charts in different ways, and to +// ensure the stacked event charts are aligned with the base vis chart. +export const augmentEventChartSpec = ( + config: VisAugmenterEmbeddableConfig, + origSpec: object +): {} => { + const inFlyout = get(config, 'inFlyout', false) as boolean; + const flyoutContext = get(config, 'flyoutContext', VisFlyoutContext.BASE_VIS); + + const newVconcat = [] as Array<{}>; + // @ts-ignore + const newConfig = origSpec?.config; + const visChart = get(origSpec, 'vconcat[0]', {}); + const eventChart = get(origSpec, 'vconcat[1]', {}); + + if (inFlyout) { + switch (flyoutContext) { + case VisFlyoutContext.BASE_VIS: + newConfig.legend = { + ...newConfig.legend, + orient: 'top', + // need to set offset to 0 so we don't cut off the chart canvas within the embeddable + offset: 0, + }; + break; + + case VisFlyoutContext.EVENT_VIS: + eventChart.encoding.x.axis = { + domain: true, + grid: false, + ticks: false, + labels: false, + title: null, + }; + eventChart.mark.fillOpacity = 0; + break; + + case VisFlyoutContext.TIMELINE_VIS: + eventChart.transform = [ + { + filter: 'false', + }, + ]; + break; + } + + // if coming from view events page, need to standardize the y axis padding values so we can + // align all of the charts correctly + newConfig.axisY = { + // We need minExtent and maxExtent to be the same. We cannot calculate these on-the-fly + // so we need to force a static value. We choose 40 as a good middleground for sufficient + // axis space without taking up too much actual chart space. + minExtent: 40, + maxExtent: 40, + offset: 0, + translate: 0, + domainWidth: 1, + labelPadding: 2, + titlePadding: 2, + tickOffset: 0, + tickSize: 5, + } as YAxisConfig; + } + + if (flyoutContext === VisFlyoutContext.BASE_VIS) { + newVconcat.push(visChart); + } + newVconcat.push(eventChart); + + return { + ...cloneDeep(origSpec), + config: newConfig, + vconcat: newVconcat, + }; +}; diff --git a/src/plugins/vis_augmenter/public/vega/index.ts b/src/plugins/vis_augmenter/public/vega/index.ts new file mode 100644 index 00000000000..0e8ad44d2bd --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './helpers'; 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 00000000000..cd333ed9451 --- /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 00000000000..c9f6d75e119 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout.tsx @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { toMountPoint } from '../../../../opensearch_dashboards_react/public'; +import { ViewEventsFlyout } from '../components'; +import { VIEW_EVENTS_FLYOUT_STATE, setFlyoutState } from '../flyout_state'; +import { getCore } from '../../services'; + +interface Props { + savedObjectId: string; +} + +export async function openViewEventsFlyout(props: Props) { + setFlyoutState(VIEW_EVENTS_FLYOUT_STATE.OPEN); + const flyoutSession = getCore().overlays.openFlyout( + toMountPoint( + { + if (flyoutSession) { + flyoutSession.close(); + setFlyoutState(VIEW_EVENTS_FLYOUT_STATE.CLOSED); + } + }} + 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 00000000000..e6cb654ab42 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenEventsFlyoutAction } from './open_events_flyout_action'; +import flyoutStateModule from '../flyout_state'; +import servicesModule from '../../services'; + +// Mocking the flyout state service. Defaulting to CLOSED. May override +// getFlyoutState() in below individual tests to test out different scenarios. +jest.mock('src/plugins/vis_augmenter/public/view_events_flyout/flyout_state', () => { + return { + VIEW_EVENTS_FLYOUT_STATE: { + OPEN: 'OPEN', + CLOSED: 'CLOSED', + }, + getFlyoutState: () => 'CLOSED', + setFlyoutState: () => {}, + }; +}); + +// Mocking core service as needed when making calls to the core's overlays service +jest.mock('src/plugins/vis_augmenter/public/services.ts', () => { + return { + getCore: () => { + return { + overlays: { + openFlyout: () => {}, + }, + }; + }, + }; +}); + +afterEach(async () => { + jest.clearAllMocks(); +}); + +describe('OpenEventsFlyoutAction', () => { + it('is incompatible with null saved obj id', async () => { + const action = new OpenEventsFlyoutAction(); + 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(); + 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(); + const savedObjectId = ''; + expect(await action.isCompatible({ savedObjectId })).toBe(false); + }); + + it('execute throws error if incompatible saved obj id', async () => { + const action = new OpenEventsFlyoutAction(); + 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 and flyout is closed', async () => { + const getFlyoutStateSpy = jest + .spyOn(flyoutStateModule, 'getFlyoutState') + .mockImplementation(() => 'CLOSED'); + // openFlyout exists within core.overlays service. We spy on the initial getCore() fn call indicating + // that openFlyout is getting called. + const openFlyoutStateSpy = jest.spyOn(servicesModule, 'getCore'); + const savedObjectId = 'test-id'; + const action = new OpenEventsFlyoutAction(); + await action.execute({ savedObjectId }); + expect(openFlyoutStateSpy).toHaveBeenCalledTimes(1); + expect(getFlyoutStateSpy).toHaveBeenCalledTimes(1); + }); + + it('execute does not call openFlyout if compatible saved obj id and flyout is open', async () => { + const getFlyoutStateSpy = jest + .spyOn(flyoutStateModule, 'getFlyoutState') + .mockImplementation(() => 'OPEN'); + const openFlyoutStateSpy = jest.spyOn(servicesModule, 'getCore'); + const savedObjectId = 'test-id'; + const action = new OpenEventsFlyoutAction(); + await action.execute({ savedObjectId }); + expect(openFlyoutStateSpy).toHaveBeenCalledTimes(0); + expect(getFlyoutStateSpy).toHaveBeenCalledTimes(1); + }); + + it('Returns display name', async () => { + const action = new OpenEventsFlyoutAction(); + expect(action.getDisplayName()).toBeDefined(); + }); + + it('Returns undefined icon type', async () => { + const action = new OpenEventsFlyoutAction(); + 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 00000000000..cb47e5d6a85 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts @@ -0,0 +1,60 @@ +/* + * 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 '../../ui_actions_bootstrap'; +import { openViewEventsFlyout } from './open_events_flyout'; +import { VIEW_EVENTS_FLYOUT_STATE, getFlyoutState } from '../flyout_state'; + +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() {} + + 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(); + } + + // This action may get triggered even when the flyout is already open (e.g., + // clicking on an annotation point within a chart displayed in the flyout). + // In such case, we want to ignore it such that users can't keep endlessly + // re-opening it. + if (getFlyoutState() === VIEW_EVENTS_FLYOUT_STATE.CLOSED) { + openViewEventsFlyout({ + 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 00000000000..cabca0f0dcd --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ViewEventsOptionAction } from './view_events_option_action'; +import { createMockErrorEmbeddable, createMockVisEmbeddable } from '../../mocks'; +import flyoutStateModule from '../flyout_state'; +import servicesModule from '../../services'; + +// Mocking the flyout state service. Defaulting to CLOSED. May override +// getFlyoutState() in below individual tests to test out different scenarios. +jest.mock('src/plugins/vis_augmenter/public/view_events_flyout/flyout_state', () => { + return { + VIEW_EVENTS_FLYOUT_STATE: { + OPEN: 'OPEN', + CLOSED: 'CLOSED', + }, + getFlyoutState: () => 'CLOSED', + setFlyoutState: () => {}, + }; +}); + +// Mocking the UISettings service. This is needed when making eligibility checks for the actions, +// which does UISettings checks to ensure the feature is enabled. +// Also mocking core service as needed when making calls to the core's overlays service +jest.mock('src/plugins/vis_augmenter/public/services.ts', () => { + return { + 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.`); + } + }, + }; + }, + getCore: () => { + return { + overlays: { + openFlyout: () => {}, + }, + }; + }, + getSavedAugmentVisLoader: () => { + return { + delete: () => {}, + findAll: () => { + return { + hits: [], + }; + }, + }; + }, + }; +}); + +afterEach(async () => { + jest.clearAllMocks(); +}); + +describe('ViewEventsOptionAction', () => { + it('is incompatible with ErrorEmbeddables', async () => { + const action = new ViewEventsOptionAction(); + const errorEmbeddable = createMockErrorEmbeddable(); + expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false); + }); + + it('is incompatible with VisualizeEmbeddable with invalid vis', async () => { + const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title', false); + const action = new ViewEventsOptionAction(); + expect(await action.isCompatible({ embeddable: visEmbeddable })).toBe(false); + }); + + it('is incompatible with VisualizeEmbeddable with valid vis and no vislayers', async () => { + const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title'); + visEmbeddable.visLayers = []; + const action = new ViewEventsOptionAction(); + expect(await action.isCompatible({ embeddable: visEmbeddable })).toBe(false); + }); + + it('is compatible with VisualizeEmbeddable with valid vis', async () => { + const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title'); + const action = new ViewEventsOptionAction(); + expect(await action.isCompatible({ embeddable: visEmbeddable })).toBe(true); + }); + + it('execute throws error if incompatible embeddable', async () => { + const errorEmbeddable = createMockErrorEmbeddable(); + const action = new ViewEventsOptionAction(); + async function check() { + await action.execute({ embeddable: errorEmbeddable }); + } + await expect(check()).rejects.toThrow(Error); + }); + + it('execute calls openFlyout if compatible embeddable and flyout is currently closed', async () => { + const getFlyoutStateSpy = jest + .spyOn(flyoutStateModule, 'getFlyoutState') + .mockImplementation(() => 'CLOSED'); + // openFlyout exists within core.overlays service. We spy on the initial getCore() fn call indicating + // that openFlyout is getting called. + const openFlyoutStateSpy = jest.spyOn(servicesModule, 'getCore'); + const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title'); + const action = new ViewEventsOptionAction(); + await action.execute({ embeddable: visEmbeddable }); + expect(openFlyoutStateSpy).toHaveBeenCalledTimes(1); + expect(getFlyoutStateSpy).toHaveBeenCalledTimes(1); + }); + + it('execute does not call openFlyout if compatible embeddable and flyout is currently open', async () => { + const getFlyoutStateSpy = jest + .spyOn(flyoutStateModule, 'getFlyoutState') + .mockImplementation(() => 'OPEN'); + const openFlyoutStateSpy = jest.spyOn(servicesModule, 'getCore'); + const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title'); + const action = new ViewEventsOptionAction(); + await action.execute({ embeddable: visEmbeddable }); + expect(openFlyoutStateSpy).toHaveBeenCalledTimes(0); + expect(getFlyoutStateSpy).toHaveBeenCalledTimes(1); + }); + + it('Returns display name', async () => { + const action = new ViewEventsOptionAction(); + expect(action.getDisplayName()).toBeDefined(); + }); + + it('Returns an icon type', async () => { + const action = new ViewEventsOptionAction(); + 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 00000000000..6410e8a1363 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx @@ -0,0 +1,72 @@ +/* + * 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, isEmpty } from 'lodash'; +import { VisualizeEmbeddable } from '../../../../visualizations/public'; +import { EmbeddableContext } from '../../../../embeddable/public'; +import { Action, IncompatibleActionError } from '../../../../ui_actions/public'; +import { openViewEventsFlyout } from './open_events_flyout'; +import { isEligibleForVisLayers } from '../../utils'; +import { VIEW_EVENTS_FLYOUT_STATE, getFlyoutState } from '../flyout_state'; + +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; + + public grouping: Action['grouping'] = [ + { + id: VIEW_EVENTS_OPTION_ACTION, + getDisplayName: this.getDisplayName, + getIconType: this.getIconType, + category: 'vis_augmenter', + order: 10, + }, + ]; + + constructor() {} + + public getIconType(): EuiIconType { + return 'inspect'; + } + + public getDisplayName() { + return i18n.translate('dashboard.actions.viewEvents.displayName', { + defaultMessage: 'View Events', + }); + } + + public async isCompatible({ embeddable }: EmbeddableContext) { + const vis = (embeddable as VisualizeEmbeddable).vis; + return ( + vis !== undefined && + isEligibleForVisLayers(vis) && + !isEmpty((embeddable as VisualizeEmbeddable).visLayers) + ); + } + + public async execute({ embeddable }: EmbeddableContext) { + if (!(await this.isCompatible({ embeddable }))) { + throw new IncompatibleActionError(); + } + + const visEmbeddable = embeddable as VisualizeEmbeddable; + const savedObjectId = get(visEmbeddable.getInput(), 'savedObjectId', ''); + + // This action may get triggered even when the flyout is already open (e.g., + // clicking on an annotation point within a chart displayed in the flyout). + // In such case, we want to ignore it such that users can't keep endlessly + // re-opening it. + if (getFlyoutState() === VIEW_EVENTS_FLYOUT_STATE.CLOSED) { + openViewEventsFlyout({ + 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 00000000000..7094a4660db --- /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 00000000000..6b642518fc6 --- /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 00000000000..ca88941f6f2 --- /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 00000000000..3840c5a1f23 --- /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 00000000000..bd07e115d15 --- /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 00000000000..e2a7092f1e5 --- /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 00000000000..d3bb447ae93 --- /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 00000000000..1e0349aa18c --- /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 00000000000..99a865a9218 --- /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 00000000000..cc00610c4a3 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item.tsx @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { get } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { getEmbeddable, getCore } from '../../services'; +import './styles.scss'; +import { EventVisEmbeddableItem } from '.'; +import { EventVisItemIcon } from './event_vis_item_icon'; + +interface Props { + item: EventVisEmbeddableItem; +} + +export function EventVisItem(props: Props) { + const PanelComponent = getEmbeddable().getEmbeddablePanel(); + const baseUrl = getCore().http.basePath; + + const name = get(props, 'item.visLayer.pluginResource.name', ''); + const urlPath = get(props, 'item.visLayer.pluginResource.urlPath', ''); + + return ( + <> + + + + + + {name} + + + + + + + + + + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item_icon.test.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item_icon.test.tsx new file mode 100644 index 00000000000..5ae91d4fa1e --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item_icon.test.tsx @@ -0,0 +1,51 @@ +/* + * 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 { EventVisItemIcon } from './event_vis_item_icon'; +import { EuiPopover } from '@elastic/eui'; +import { createPluginResource, createPointInTimeEventsVisLayer } from '../../mocks'; + +describe('', () => { + it('shows event count when rendering a PointInTimeEventsVisLayer', async () => { + const eventCount = 5; + const pluginResource = createPluginResource(); + const visLayer = createPointInTimeEventsVisLayer('test-plugin', pluginResource, eventCount); + const { getByTestId, getByText } = render(); + expect(getByTestId('eventCount')).toBeInTheDocument(); + expect(getByText(eventCount)).toBeInTheDocument(); + }); + it('shows error when rendering a PointInTimeEventsVisLayer with an error', async () => { + const eventCount = 5; + const pluginResource = createPluginResource(); + const visLayerWithError = createPointInTimeEventsVisLayer( + 'test-plugin', + pluginResource, + eventCount, + true + ); + const { getByTestId } = render(); + expect(getByTestId('errorButton')).toBeInTheDocument(); + }); + it('triggers popout with error message when clicking on error button', async () => { + const eventCount = 5; + const pluginResource = createPluginResource(); + const visLayerWithError = createPointInTimeEventsVisLayer( + 'test-plugin', + pluginResource, + eventCount, + true, + 'some-error-message' + ); + const component = mountWithIntl(); + const errorButton = findTestSubject(component, 'dangerButton'); + errorButton.simulate('click'); + expect(component.find(EuiPopover).prop('isOpen')).toBe(true); + expect(component.contains('some-error-message')).toBe(true); + }); +}); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item_icon.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item_icon.tsx new file mode 100644 index 00000000000..8c00b72b85c --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/event_vis_item_icon.tsx @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { get } from 'lodash'; +import { EuiFlexItem, EuiNotificationBadge, EuiButtonIcon, EuiPopover } from '@elastic/eui'; +import './styles.scss'; +import { VisLayer, VisLayerTypes } from '../../'; + +interface Props { + visLayer: VisLayer; +} + +/** + * Returns a badge with the event count for this particular VisLayer (only PointInTimeEventVisLayers + * are currently supported), or an error icon which can be clicked to view the error message. + */ +export function EventVisItemIcon(props: Props) { + const [isErrorPopoverOpen, setIsErrorPopoverOpen] = useState(false); + const onButtonClick = () => setIsErrorPopoverOpen((isOpen) => !isOpen); + const closeErrorPopover = () => setIsErrorPopoverOpen(false); + + const errorMsg = get(props, 'visLayer.error.message', undefined) as string | undefined; + const isError = errorMsg !== undefined; + const showEventCount = props.visLayer.type === VisLayerTypes.PointInTimeEvents && !isError; + + const dangerButton = ( + + ); + + return ( + <> + {showEventCount ? ( + + + {get(props.visLayer, 'events.length', 0)} + + + ) : isError ? ( + + +
{errorMsg}
+
+
+ ) : 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 00000000000..33f3ea8bb20 --- /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 00000000000..70564145711 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ViewEventsFlyout } from './view_events_flyout'; +export { EventVisEmbeddablesMap, EventVisEmbeddableItem } from './types'; +export { fetchVisEmbeddable } from './utils'; 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 00000000000..0a06516831d --- /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 00000000000..90a6d521302 --- /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 00000000000..0f737f2c058 --- /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 00000000000..6358f6b51f3 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/styles.scss @@ -0,0 +1,60 @@ +$vis-description-width: 200px; +$event-vis-height: 55px; +$timeline-panel-height: 100px; +$content-padding-top: 110px; // Padding needed within view events flyout content to sit comfortably below flyout header +$date-range-height: 45px; // Static height we want for the date range picker component +$error-icon-padding-right: -8px; // This is so the error icon is aligned consistent with the event count icons +$base-vis-min-height: 25vh; // Visualizations require the container to have a valid width and height to render + +.view-events-flyout { + &__baseVis { + min-height: $base-vis-min-height; + } + + &__eventVis { + height: $event-vis-height; + } + + &__timelinePanel { + height: $timeline-panel-height; + } + + &__visDescription { + min-width: $vis-description-width; + max-width: $vis-description-width; + word-break: break-word; + } + + &__content { + position: absolute; + top: $content-padding-top; + right: $euiSizeM; + bottom: $euiSizeM; + left: $euiSizeM; + } + + &__contentPanel { + @include euiYScroll; + + overflow: auto; + overflow-x: hidden; + overflow-y: hidden; + scrollbar-gutter: stable both-edges; + } +} + +.show-y-scroll { + overflow-y: scroll; +} + +.date-range-panel-height { + height: $date-range-height; +} + +.timeline-panel-height { + height: $timeline-panel-height; +} + +.error-icon-padding { + margin-right: $error-icon-padding-right; +} 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 00000000000..a22ac5aa209 --- /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 00000000000..6507eac8cc2 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/timeline_panel.tsx @@ -0,0 +1,35 @@ +/* + * 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 ( + + + + + + + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/types.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/components/types.tsx new file mode 100644 index 00000000000..c70617e6665 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/types.tsx @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisLayer } from '../../types'; +import { VisualizeEmbeddable } from '../../../../visualizations/public'; + +export interface EventVisEmbeddableItem { + visLayer: VisLayer; + embeddable: VisualizeEmbeddable; +} + +export type EventVisEmbeddablesMap = Map; 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 00000000000..39ff9d53dd4 --- /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 00000000000..4c06d3b87f0 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/utils.tsx @@ -0,0 +1,243 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get } from 'lodash'; +import { EmbeddableStart, ErrorEmbeddable } from '../../../../embeddable/public'; +import { VisualizeEmbeddable, VisualizeInput } from '../../../../visualizations/public'; +import { getEmbeddable, getQueryService } from '../../services'; +import { + isPointInTimeEventsVisLayer, + PointInTimeEventsVisLayer, + VisFlyoutContext, + VisLayer, +} from '../../types'; +import { EventVisEmbeddableItem, EventVisEmbeddablesMap } from './types'; +import { QueryStart } from '../../../../data/public'; + +export function getErrorMessage(errorEmbeddable: ErrorEmbeddable): string { + return errorEmbeddable.error instanceof Error + ? errorEmbeddable.error.message + : errorEmbeddable.error; +} + +/** + * Given an embeddable, check if/where there is value (y) axes located on the left and/or + * right of the chart. This is needed so we can properly align all of the event + * charts in the flyout appropriately. + */ +function getValueAxisPositions(embeddable: VisualizeEmbeddable): { left: boolean; right: boolean } { + let hasLeftValueAxis = false; + let hasRightValueAxis = false; + if (embeddable !== undefined) { + const valueAxes = embeddable.vis.params.valueAxes; + const positions = valueAxes.map( + (valueAxis: { position: string }) => valueAxis.position + ) as string[]; + hasLeftValueAxis = positions.includes('left'); + hasRightValueAxis = positions.includes('right'); + } + return { + left: hasLeftValueAxis, + right: hasRightValueAxis, + }; +} + +/** + * Fetching the base vis to show in the flyout, based on the saved object ID. Add constraints + * such that it is static and won't auto-refresh within the flyout. + * @param savedObjectId the saved object id of the base vis + * @param embeddableStart Optional EmbeddableStart passed in for plugins to utilize the function + * @param queryServiceLoader Optional QueryStart passed in for plugins to utilize the function + */ +export async function fetchVisEmbeddable( + savedObjectId: string, + embeddableStart?: EmbeddableStart, + queryStart?: QueryStart +): Promise { + const embeddableLoader = embeddableStart ?? getEmbeddable(); + const embeddableVisFactory = embeddableLoader.getEmbeddableFactory('visualization'); + const queryService = queryStart ?? getQueryService(); + const contextInput = { + filters: queryService.filterManager.getFilters(), + query: queryService.queryString.getQuery(), + timeRange: queryService.timefilter.timefilter.getTime(), + }; + + const embeddable = (await embeddableVisFactory?.createFromSavedObject(savedObjectId, { + ...contextInput, + visAugmenterConfig: { + inFlyout: true, + flyoutContext: VisFlyoutContext.BASE_VIS, + }, + } as VisualizeInput)) as VisualizeEmbeddable | ErrorEmbeddable; + + if (embeddable instanceof ErrorEmbeddable) { + throw getErrorMessage(embeddable); + } + + embeddable.updateInput({ + // @ts-ignore + refreshConfig: { + value: 0, + pause: true, + }, + }); + + // By waiting for this to complete, embeddable.visLayers will be populated + await embeddable.populateVisLayers(); + + return embeddable; +} + +/** + * Fetching the base vis to show in the flyout, based on the saved object ID. Add constraints + * such that it is static and won't auto-refresh within the flyout. + * @param savedObjectId the saved object id of the base vis + * @param setTimeRange custom hook used in base component + * @param setVisEmbeddable custom hook used in base component + * @param setErrorMessage custom hook used in base component + */ +export async function fetchVisEmbeddableWithSetters( + savedObjectId: string, + setTimeRange: Function, + setVisEmbeddable: Function, + setErrorMessage: Function +): Promise { + try { + const embeddable = await fetchVisEmbeddable(savedObjectId); + setTimeRange(getQueryService().timefilter.timefilter.getTime()); + setVisEmbeddable(embeddable); + } catch (err: any) { + setErrorMessage(String(err)); + } +} + +/** + * For each VisLayer in the base vis embeddable, generate a new filtered vis + * embeddable (based off of the base vis), and pass in extra arguments to only + * show datapoints for that particular VisLayer. Partition them by + * plugin resource type via an EventVisEmbeddablesMap. + * @param savedObjectId the saved object id of the base vis embeddable + * @param embeddable the base vis embeddable + * @param setEventVisEmbeddablesMap custom hook used in base component + * @param setErrorMessage custom hook used in base component + */ +export async function createEventEmbeddables( + savedObjectId: string, + embeddable: VisualizeEmbeddable, + setEventVisEmbeddablesMap: Function, + setErrorMessage: Function +) { + const embeddableVisFactory = getEmbeddable().getEmbeddableFactory('visualization'); + try { + const { left, right } = getValueAxisPositions(embeddable); + const map = new Map() as EventVisEmbeddablesMap; + // Currently only support PointInTimeEventVisLayers. Different layer types + // may require different logic in here + const visLayers = (get(embeddable, '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( + savedObjectId, + { + ...contextInput, + visAugmenterConfig: { + visLayerResourceIds: [visLayer.pluginResource.id as string], + inFlyout: true, + flyoutContext: VisFlyoutContext.EVENT_VIS, + leftValueAxisPadding: left, + rightValueAxisPadding: right, + }, + } 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)); + } +} + +/** + * Based on the base vis embeddable, generate a new filtered vis, and pass in extra + * arguments to only show the x-axis (timeline). + * @param savedObjectId the saved object id of the base vis + * @param embeddable the base vis embeddable + * @param setTimelineVisEmbeddable custom hook used in base component + * @param setErrorMessage custom hook used in base component + */ +export async function createTimelineEmbeddable( + savedObjectId: string, + embeddable: VisualizeEmbeddable, + setTimelineVisEmbeddable: Function, + setErrorMessage: Function +) { + const embeddableVisFactory = getEmbeddable().getEmbeddableFactory('visualization'); + try { + const { left, right } = getValueAxisPositions(embeddable); + const contextInput = { + filters: embeddable.getInput().filters, + query: embeddable.getInput().query, + timeRange: embeddable.getInput().timeRange, + }; + + const timelineEmbeddable = (await embeddableVisFactory?.createFromSavedObject(savedObjectId, { + ...contextInput, + visAugmenterConfig: { + inFlyout: true, + flyoutContext: VisFlyoutContext.TIMELINE_VIS, + leftValueAxisPadding: left, + rightValueAxisPadding: right, + }, + } as VisualizeInput)) 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)); + } +} 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 00000000000..6cd688e78c8 --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/view_events_flyout.tsx @@ -0,0 +1,151 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyout, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, +} from '@elastic/eui'; +import './styles.scss'; +import { VisualizeEmbeddable } from '../../../../visualizations/public'; +import { TimeRange } from '../../../../data/common'; +import { BaseVisItem } from './base_vis_item'; +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 { + fetchVisEmbeddableWithSetters, + createEventEmbeddables, + createTimelineEmbeddable, +} from './utils'; +import { EventVisEmbeddablesMap } from './types'; + +interface Props { + onClose: () => void; + savedObjectId: string; +} + +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); + + function reload() { + visEmbeddable?.reload(); + eventVisEmbeddablesMap?.forEach((embeddableItems) => { + embeddableItems.forEach((embeddableItem) => { + embeddableItem.embeddable.reload(); + }); + }); + } + + useEffect(() => { + fetchVisEmbeddableWithSetters( + props.savedObjectId, + setTimeRange, + setVisEmbeddable, + setErrorMessage + ); + // adding all of the values to the deps array cause a circular re-render. we don't want + // to keep re-fetching the visEmbeddable after it is set. + /* eslint-disable react-hooks/exhaustive-deps */ + }, [props.savedObjectId]); + + useEffect(() => { + if (visEmbeddable?.visLayers) { + createEventEmbeddables( + props.savedObjectId, + visEmbeddable, + setEventVisEmbeddablesMap, + setErrorMessage + ); + createTimelineEmbeddable( + props.savedObjectId, + visEmbeddable, + setTimelineVisEmbeddable, + setErrorMessage + ); + } + }, [visEmbeddable?.visLayers]); + + useEffect(() => { + if ( + visEmbeddable !== undefined && + eventVisEmbeddablesMap !== undefined && + timeRange !== undefined && + timelineVisEmbeddable !== undefined + ) { + setIsLoading(false); + } + }, [visEmbeddable, eventVisEmbeddablesMap, timeRange, timelineVisEmbeddable]); + + return ( + <> + + + +

+ {isLoading ? ( + + ) : errorMessage ? ( + 'Error fetching events' + ) : ( + `${visEmbeddable?.getTitle()}` + )} +

+
+
+ {errorMessage ? ( + + ) : isLoading ? ( + + ) : ( + + + + + + + + + + + + + + + + + )} +
+ + ); +} diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/flyout_state.ts b/src/plugins/vis_augmenter/public/view_events_flyout/flyout_state.ts new file mode 100644 index 00000000000..4db90ed977e --- /dev/null +++ b/src/plugins/vis_augmenter/public/view_events_flyout/flyout_state.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createGetterSetter } from '../../../opensearch_dashboards_utils/public'; + +export enum VIEW_EVENTS_FLYOUT_STATE { + OPEN = 'OPEN', + CLOSED = 'CLOSED', +} + +export const [getFlyoutState, setFlyoutState] = createGetterSetter< + keyof typeof VIEW_EVENTS_FLYOUT_STATE +>(VIEW_EVENTS_FLYOUT_STATE.CLOSED); + +// This is primarily used for mocking this module and each of its fns in tests. +// eslint-disable-next-line import/no-default-export +export default { VIEW_EVENTS_FLYOUT_STATE, getFlyoutState, setFlyoutState }; 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 00000000000..3f1da0cedbb --- /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 './components'; +export * from './flyout_state'; diff --git a/src/plugins/vis_augmenter/server/capabilities_provider.ts b/src/plugins/vis_augmenter/server/capabilities_provider.ts new file mode 100644 index 00000000000..db7bfc2b539 --- /dev/null +++ b/src/plugins/vis_augmenter/server/capabilities_provider.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const capabilitiesProvider = () => ({ + visAugmenter: { + show: false, + delete: true, + save: true, + saveQuery: true, + }, +}); diff --git a/src/plugins/vis_augmenter/server/index.ts b/src/plugins/vis_augmenter/server/index.ts new file mode 100644 index 00000000000..9fb8b7b695f --- /dev/null +++ b/src/plugins/vis_augmenter/server/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; +import { VisAugmenterPlugin } from './plugin'; +import { configSchema, VisAugmenterPluginConfigType } from '../config'; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + pluginAugmentationEnabled: true, + }, + schema: configSchema, +}; +export function plugin(initializerContext: PluginInitializerContext) { + return new VisAugmenterPlugin(initializerContext); +} +export { VisAugmenterPluginSetup, VisAugmenterPluginStart } from './plugin'; diff --git a/src/plugins/vis_augmenter/server/plugin.ts b/src/plugins/vis_augmenter/server/plugin.ts new file mode 100644 index 00000000000..e482265ee29 --- /dev/null +++ b/src/plugins/vis_augmenter/server/plugin.ts @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { schema } from '@osd/config-schema'; +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../core/server'; +import { augmentVisSavedObjectType } from './saved_objects'; +import { capabilitiesProvider } from './capabilities_provider'; +import { VisAugmenterPluginConfigType } from '../config'; +import { + PLUGIN_AUGMENTATION_ENABLE_SETTING, + PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING, +} from '../common/constants'; +import { registerStatsRoute } from './routes/stats'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisAugmenterPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisAugmenterPluginStart {} + +export class VisAugmenterPlugin + implements Plugin { + private readonly logger: Logger; + private readonly config$: Observable; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + this.config$ = initializerContext.config.create(); + } + + public async setup(core: CoreSetup) { + this.logger.debug('VisAugmenter: Setup'); + core.savedObjects.registerType(augmentVisSavedObjectType); + core.capabilities.registerProvider(capabilitiesProvider); + + const config: VisAugmenterPluginConfigType = await this.config$.pipe(first()).toPromise(); + const isAugmentationEnabled = + config.pluginAugmentationEnabled === undefined ? true : config.pluginAugmentationEnabled; + + // Checks if the global yaml setting for enabling plugin augmentation is disabled. + // If it is disabled, remove the settings as we would not want to show these to the + // user due to it being disabled at the cluster level. + if (isAugmentationEnabled) { + core.uiSettings.register({ + [PLUGIN_AUGMENTATION_ENABLE_SETTING]: { + name: i18n.translate('visualization.enablePluginAugmentationTitle', { + defaultMessage: 'Enable plugin augmentation', + }), + value: true, + description: i18n.translate('visualization.enablePluginAugmentationText', { + defaultMessage: 'Plugin functionality can be accessed from line chart visualizations', + }), + category: ['visualization'], + schema: schema.boolean(), + }, + [PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING]: { + name: i18n.translate('visualization.enablePluginAugmentation.maxPluginObjectsTitle', { + defaultMessage: 'Max number of associated augmentations', + }), + value: 10, + description: i18n.translate( + 'visualization.enablePluginAugmentation.maxPluginObjectsText', + { + defaultMessage: + 'Associating more than 10 plugin resources per visualization can lead to performance ' + + 'issues and increase the cost of running clusters.', + } + ), + category: ['visualization'], + schema: schema.number({ min: 0 }), + }, + }); + } + + // Register server-side APIs + const router = core.http.createRouter(); + registerStatsRoute(router, this.logger); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('VisAugmenter: Started'); + return {}; + } + + public stop() {} +} diff --git a/src/plugins/vis_augmenter/server/routes/stats.ts b/src/plugins/vis_augmenter/server/routes/stats.ts new file mode 100644 index 00000000000..ffc038233bc --- /dev/null +++ b/src/plugins/vis_augmenter/server/routes/stats.ts @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Logger } from '@osd/logging'; +import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; +import { + IOpenSearchDashboardsResponse, + IRouter, + SavedObjectsFindResponse, +} from '../../../../core/server'; +import { APP_API, APP_PATH, AugmentVisSavedObjectAttributes } from '../../common'; +import { PER_PAGE_VALUE } from '../../../saved_objects/common'; +import { getAugmentVisSavedObjects, getStats } from './stats_helpers'; + +export const registerStatsRoute = (router: IRouter, logger: Logger) => { + router.get( + { + path: `${APP_API}${APP_PATH.STATS}`, + validate: {}, + }, + async ( + context, + request, + response + ): Promise> => { + try { + const savedObjectsClient = context.core.savedObjects.client; + const augmentVisSavedObjects: SavedObjectsFindResponse = await getAugmentVisSavedObjects( + savedObjectsClient, + PER_PAGE_VALUE + ); + const stats = getStats(augmentVisSavedObjects); + return response.ok({ + body: stats, + }); + } catch (error: any) { + logger.error(error); + return response.customError({ + statusCode: error.statusCode || 500, + body: { + message: error.message, + attributes: { + error: error.body?.error || error.message, + }, + }, + }); + } + } + ); +}; diff --git a/src/plugins/vis_augmenter/server/routes/stats_helpers.test.ts b/src/plugins/vis_augmenter/server/routes/stats_helpers.test.ts new file mode 100644 index 00000000000..96f561c49e0 --- /dev/null +++ b/src/plugins/vis_augmenter/server/routes/stats_helpers.test.ts @@ -0,0 +1,203 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsFindResult } from '../../../../../src/core/server'; +import { AugmentVisSavedObjectAttributes } from '../../common'; +import { getAugmentVisSavedObjects, getStats } from './stats_helpers'; + +const ORIGIN_PLUGIN_1 = 'origin-plugin-1'; +const ORIGIN_PLUGIN_2 = 'origin-plugin-2'; +const PLUGIN_RESOURCE_TYPE_1 = 'plugin-resource-type-1'; +const PLUGIN_RESOURCE_TYPE_2 = 'plugin-resource-type-2'; +const PLUGIN_RESOURCE_ID_1 = 'plugin-resource-id-1'; +const PLUGIN_RESOURCE_ID_2 = 'plugin-resource-id-2'; +const PLUGIN_RESOURCE_ID_3 = 'plugin-resource-id-3'; +const VIS_ID_1 = 'vis-id-1'; +const VIS_ID_2 = 'vis-id-2'; +const PER_PAGE = 4; + +const SINGLE_SAVED_OBJ = [ + { + attributes: { + originPlugin: ORIGIN_PLUGIN_1, + pluginResource: { + type: PLUGIN_RESOURCE_TYPE_1, + id: PLUGIN_RESOURCE_ID_1, + }, + visId: VIS_ID_1, + }, + }, +] as Array>; + +const MULTIPLE_SAVED_OBJS = [ + { + attributes: { + originPlugin: ORIGIN_PLUGIN_1, + pluginResource: { + type: PLUGIN_RESOURCE_TYPE_1, + id: PLUGIN_RESOURCE_ID_1, + }, + visId: VIS_ID_1, + }, + }, + { + attributes: { + originPlugin: ORIGIN_PLUGIN_2, + pluginResource: { + type: PLUGIN_RESOURCE_TYPE_2, + id: PLUGIN_RESOURCE_ID_2, + }, + visId: VIS_ID_1, + }, + }, + { + attributes: { + originPlugin: ORIGIN_PLUGIN_2, + pluginResource: { + type: PLUGIN_RESOURCE_TYPE_2, + id: PLUGIN_RESOURCE_ID_2, + }, + visId: VIS_ID_2, + }, + }, + { + attributes: { + originPlugin: ORIGIN_PLUGIN_2, + pluginResource: { + type: PLUGIN_RESOURCE_TYPE_2, + id: PLUGIN_RESOURCE_ID_3, + }, + visId: VIS_ID_1, + }, + }, +] as Array>; + +describe('getAugmentVisSavedObjs()', function () { + const mockClient = { + find: jest.fn(), + }; + it('should return empty arr if no objs found', async function () { + mockClient.find.mockResolvedValueOnce({ + total: 0, + page: 1, + per_page: PER_PAGE, + saved_objects: [], + }); + + // @ts-ignore + const response = await getAugmentVisSavedObjects(mockClient, PER_PAGE); + expect(response.total).toEqual(0); + expect(response.saved_objects).toHaveLength(0); + }); + + it('should return all augment-vis saved objects', async function () { + mockClient.find.mockResolvedValueOnce({ + total: 4, + page: 1, + per_page: PER_PAGE, + saved_objects: MULTIPLE_SAVED_OBJS, + }); + + // @ts-ignore + const response = await getAugmentVisSavedObjects(mockClient, PER_PAGE); + expect(response.total).toEqual(4); + expect(response.saved_objects).toHaveLength(4); + expect(response.saved_objects[0].attributes.originPlugin).toEqual(ORIGIN_PLUGIN_1); + expect(response.saved_objects[1].attributes.originPlugin).toEqual(ORIGIN_PLUGIN_2); + expect(response.saved_objects[2].attributes.originPlugin).toEqual(ORIGIN_PLUGIN_2); + expect(response.saved_objects[3].attributes.originPlugin).toEqual(ORIGIN_PLUGIN_2); + }); + + it('should correctly perform pagination', async function () { + mockClient.find + .mockResolvedValueOnce({ + total: 5, + page: 1, + per_page: PER_PAGE, + saved_objects: MULTIPLE_SAVED_OBJS, + }) + .mockResolvedValueOnce({ + total: 5, + page: 2, + per_page: PER_PAGE, + saved_objects: SINGLE_SAVED_OBJ, + }); + + // @ts-ignore + const response = await getAugmentVisSavedObjects(mockClient, PER_PAGE); + expect(response.total).toEqual(5); + expect(response.saved_objects).toHaveLength(5); + expect(response.saved_objects[0].attributes.originPlugin).toEqual(ORIGIN_PLUGIN_1); + expect(response.saved_objects[1].attributes.originPlugin).toEqual(ORIGIN_PLUGIN_2); + expect(response.saved_objects[2].attributes.originPlugin).toEqual(ORIGIN_PLUGIN_2); + expect(response.saved_objects[3].attributes.originPlugin).toEqual(ORIGIN_PLUGIN_2); + expect(response.saved_objects[4].attributes.originPlugin).toEqual(ORIGIN_PLUGIN_1); + }); +}); + +describe('getStats()', function () { + it('should return total of 0 and empty mappings on empty response', function () { + const response = getStats({ + total: 0, + page: 1, + per_page: PER_PAGE, + saved_objects: [], + }); + expect(response.total_objs).toEqual(0); + expect(response.obj_breakdown.origin_plugin).toEqual({}); + expect(response.obj_breakdown.plugin_resource_type).toEqual({}); + expect(response.obj_breakdown.plugin_resource_id).toEqual({}); + expect(response.obj_breakdown.visualization_id).toEqual({}); + }); + + it('should return correct count and mappings on single-obj response', function () { + const response = getStats({ + total: 1, + page: 1, + per_page: PER_PAGE, + saved_objects: SINGLE_SAVED_OBJ, + }); + expect(response.total_objs).toEqual(1); + expect(response.obj_breakdown.origin_plugin).toEqual({ + [ORIGIN_PLUGIN_1]: 1, + }); + expect(response.obj_breakdown.plugin_resource_type).toEqual({ + [PLUGIN_RESOURCE_TYPE_1]: 1, + }); + expect(response.obj_breakdown.plugin_resource_id).toEqual({ + [PLUGIN_RESOURCE_ID_1]: 1, + }); + expect(response.obj_breakdown.visualization_id).toEqual({ + [VIS_ID_1]: 1, + }); + }); + + it('should return correct count and mappings on multiple-obj response', function () { + const response = getStats({ + total: MULTIPLE_SAVED_OBJS.length, + page: 1, + per_page: PER_PAGE, + saved_objects: MULTIPLE_SAVED_OBJS, + }); + expect(response.total_objs).toEqual(4); + expect(response.obj_breakdown.origin_plugin).toEqual({ + [ORIGIN_PLUGIN_1]: 1, + [ORIGIN_PLUGIN_2]: 3, + }); + expect(response.obj_breakdown.plugin_resource_type).toEqual({ + [PLUGIN_RESOURCE_TYPE_1]: 1, + [PLUGIN_RESOURCE_TYPE_2]: 3, + }); + expect(response.obj_breakdown.plugin_resource_id).toEqual({ + [PLUGIN_RESOURCE_ID_1]: 1, + [PLUGIN_RESOURCE_ID_2]: 2, + [PLUGIN_RESOURCE_ID_3]: 1, + }); + expect(response.obj_breakdown.visualization_id).toEqual({ + [VIS_ID_1]: 3, + [VIS_ID_2]: 1, + }); + }); +}); diff --git a/src/plugins/vis_augmenter/server/routes/stats_helpers.ts b/src/plugins/vis_augmenter/server/routes/stats_helpers.ts new file mode 100644 index 00000000000..33e73ec4730 --- /dev/null +++ b/src/plugins/vis_augmenter/server/routes/stats_helpers.ts @@ -0,0 +1,91 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get } from 'lodash'; +import { + SavedObjectsFindResponse, + SavedObjectsClientContract, +} from '../../../../../src/core/server'; +import { AugmentVisSavedObjectAttributes } from '../../common'; + +interface ObjectBreakdown { + origin_plugin: { [key: string]: number }; + plugin_resource_type: { [key: string]: number }; + plugin_resource_id: { [key: string]: number }; + visualization_id: { [key: string]: number }; +} + +interface VisAugmenterStats { + total_objs: number; + obj_breakdown: ObjectBreakdown; +} + +export const getAugmentVisSavedObjects = async ( + savedObjectsClient: SavedObjectsClientContract, + perPage: number +): Promise> => { + const augmentVisSavedObjects: SavedObjectsFindResponse = await savedObjectsClient?.find( + { + type: 'augment-vis', + perPage, + } + ); + // If there are more than perPage of objs, we need to make additional requests + if (augmentVisSavedObjects.total > perPage) { + const iterations = Math.ceil(augmentVisSavedObjects.total / perPage); + for (let i = 1; i < iterations; i++) { + const augmentVisSavedObjectsPage: SavedObjectsFindResponse = await savedObjectsClient?.find( + { + type: 'augment-vis', + perPage, + page: i + 1, + } + ); + augmentVisSavedObjects.saved_objects = [ + ...augmentVisSavedObjects.saved_objects, + ...augmentVisSavedObjectsPage.saved_objects, + ]; + } + } + return augmentVisSavedObjects; +}; + +/** + * Given the _find response that contains all of the saved objects, iterate through them and + * increment counters for each unique value we are tracking + */ +export const getStats = ( + resp: SavedObjectsFindResponse +): VisAugmenterStats => { + const originPluginMap = {} as { [originPlugin: string]: number }; + const pluginResourceTypeMap = {} as { [pluginResourceType: string]: number }; + const pluginResourceIdMap = {} as { [pluginResourceId: string]: number }; + const visualizationIdMap = {} as { [visualizationId: string]: number }; + + resp.saved_objects.forEach((augmentVisObj) => { + const originPlugin = augmentVisObj.attributes.originPlugin; + const pluginResourceType = augmentVisObj.attributes.pluginResource.type; + const pluginResourceId = augmentVisObj.attributes.pluginResource.id; + const visualizationId = augmentVisObj.attributes.visId as string; + + originPluginMap[originPlugin] = (get(originPluginMap, originPlugin, 0) as number) + 1; + pluginResourceTypeMap[pluginResourceType] = + (get(pluginResourceTypeMap, pluginResourceType, 0) as number) + 1; + pluginResourceIdMap[pluginResourceId] = + (get(pluginResourceIdMap, pluginResourceId, 0) as number) + 1; + visualizationIdMap[visualizationId] = + (get(visualizationIdMap, visualizationId, 0) as number) + 1; + }); + + return { + total_objs: resp.total, + obj_breakdown: { + origin_plugin: originPluginMap, + plugin_resource_type: pluginResourceTypeMap, + plugin_resource_id: pluginResourceIdMap, + visualization_id: visualizationIdMap, + }, + }; +}; diff --git a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts new file mode 100644 index 00000000000..52188d52998 --- /dev/null +++ b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsType } from 'opensearch-dashboards/server'; + +export const augmentVisSavedObjectType: SavedObjectsType = { + name: 'augment-vis', + hidden: false, + namespaceType: 'single', + management: { + importableAndExportable: true, + getTitle(obj) { + return `augment-vis-${obj?.attributes?.originPlugin}`; + }, + getEditUrl(obj) { + return `/management/opensearch-dashboards/objects/savedAugmentVis/${encodeURIComponent( + obj.id + )}`; + }, + }, + mappings: { + properties: { + title: { type: 'text' }, + description: { type: 'text' }, + originPlugin: { type: 'text' }, + pluginResource: { + properties: { + type: { type: 'text' }, + id: { type: 'text' }, + }, + }, + visName: { type: 'keyword', index: false, doc_values: false }, + visLayerExpressionFn: { + properties: { + type: { type: 'text' }, + name: { type: 'text' }, + // keeping generic to not limit what users may pass as args to their fns + // users may not have this field at all, if no args are needed + args: { type: 'object', dynamic: true }, + }, + }, + version: { type: 'integer' }, + }, + }, +}; diff --git a/src/plugins/vis_augmenter/server/saved_objects/index.ts b/src/plugins/vis_augmenter/server/saved_objects/index.ts new file mode 100644 index 00000000000..b96dbd4b2a5 --- /dev/null +++ b/src/plugins/vis_augmenter/server/saved_objects/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { augmentVisSavedObjectType } from './augment_vis'; diff --git a/src/plugins/vis_type_vega/opensearch_dashboards.json b/src/plugins/vis_type_vega/opensearch_dashboards.json index ca4d7020c2f..faf10c831e6 100644 --- a/src/plugins/vis_type_vega/opensearch_dashboards.json +++ b/src/plugins/vis_type_vega/opensearch_dashboards.json @@ -3,7 +3,19 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions", "inspector"], - "optionalPlugins": ["home","usageCollection"], - "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "visDefaultEditor"] + "requiredPlugins": [ + "data", + "visualizations", + "mapsLegacy", + "expressions", + "inspector", + "uiActions" + ], + "optionalPlugins": ["home", "usageCollection"], + "requiredBundles": [ + "opensearchDashboardsUtils", + "opensearchDashboardsReact", + "visDefaultEditor", + "visAugmenter" + ] } diff --git a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap index 023b5568e65..833fbc3f108 100644 --- a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap +++ b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap @@ -2,6 +2,12 @@ exports[`VegaVisualizations VegaVisualization - basics should show vega blank rectangle on top of a map (vegamap) 1`] = `
`; +exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `
  • Cannot read properties of undefined (reading 'get')
`; + +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `
  • Cannot read properties of undefined (reading 'get')
`; + +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 2`] = `
  • Cannot read properties of undefined (reading 'get')
`; + exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `
`; exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `
  • "width" and "height" params are ignored because "autosize" is enabled. Set "autosize": "none" to disable
`; diff --git a/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx b/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx index 86343ae10b0..5fe1cbcfa6c 100644 --- a/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx +++ b/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx @@ -37,7 +37,7 @@ import { i18n } from '@osd/i18n'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; import { getNotifications } from '../services'; -import { VisParams } from '../vega_fn'; +import { VisParams } from '../expressions/vega_fn'; import { VegaHelpMenu } from './vega_help_menu'; import { VegaActionsMenu } from './vega_actions_menu'; diff --git a/src/plugins/vis_type_vega/public/data_model/types.ts b/src/plugins/vis_type_vega/public/data_model/types.ts index cd6cbc94bcc..35198f846f0 100644 --- a/src/plugins/vis_type_vega/public/data_model/types.ts +++ b/src/plugins/vis_type_vega/public/data_model/types.ts @@ -32,6 +32,8 @@ import { SearchResponse, SearchParams } from 'elasticsearch'; import { Filter } from 'src/plugins/data/public'; import { DslQuery } from 'src/plugins/data/common'; +import { Signal } from 'vega'; +import { VisAugmenterEmbeddableConfig, VisLayerTypes } from 'src/plugins/vis_augmenter/public'; import { OpenSearchQueryParser } from './opensearch_query_parser'; import { EmsFileParser } from './ems_file_parser'; import { UrlParser } from './url_parser'; @@ -113,6 +115,9 @@ export interface OpenSearchDashboards { hideWarnings: boolean; type: string; renderer: Renderer; + visibleVisLayers?: Map; + signals?: { [markId: string]: Signal[] }; + visAugmenterConfig?: VisAugmenterEmbeddableConfig; } export interface VegaSpec { diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js index 8ceb565fa8c..421b99790dc 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js @@ -33,6 +33,7 @@ import { euiThemeVars } from '@osd/ui-shared-deps/theme'; import { euiPaletteColorBlind } from '@elastic/eui'; import { VegaParser } from './vega_parser'; import { bypassExternalUrlCheck } from '../vega_view/vega_base_view'; +import { VisLayerTypes } from '../../../vis_augmenter/public'; jest.mock('../services'); @@ -388,6 +389,25 @@ describe('VegaParser._parseConfig', () => { check({ config: { kibana: { a: 1 } } }, { a: 1 }, { config: {} }) ); test('_hostConfig', check({ _hostConfig: { a: 1 } }, { a: 1 }, {}, 1)); + test( + 'visibleVisLayers conversion to map', + check( + { + config: { + kibana: { + hideWarnings: true, + visibleVisLayers: [[VisLayerTypes.PointInTimeEvents, true]], + }, + }, + }, + { + hideWarnings: true, + visibleVisLayers: new Map([[VisLayerTypes.PointInTimeEvents, true]]), + }, + { config: {} }, + 0 + ) + ); }); describe('VegaParser._compileWithAutosize', () => { 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 64ed96f7e3e..4bdd724dbea 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 @@ -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'; @@ -44,6 +45,7 @@ import { UrlParser } from './url_parser'; import { SearchAPI } from './search_api'; import { TimeCache } from './time_cache'; import { IServiceSettings } from '../../../maps_legacy/public'; +import { VisAugmenterEmbeddableConfig, VisLayerTypes } from '../../../vis_augmenter/public'; import { Bool, Data, @@ -92,6 +94,8 @@ export class VegaParser { getServiceSettings: () => Promise; filters: Bool; timeCache: TimeCache; + visibleVisLayers: Map; + visAugmenterConfig: VisAugmenterEmbeddableConfig; constructor( spec: VegaSpec | string, @@ -102,6 +106,8 @@ export class VegaParser { ) { this.spec = spec as VegaSpec; this.hideWarnings = false; + this.visibleVisLayers = new Map(); + this.visAugmenterConfig = {} as VisAugmenterEmbeddableConfig; this.error = undefined; this.warnings = []; @@ -158,6 +164,8 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never this._config = this._parseConfig(); this.hideWarnings = !!this._config.hideWarnings; + this.visibleVisLayers = this._config.visibleVisLayers; + this.visAugmenterConfig = this._config.visAugmenterConfig; this.useMap = this._config.type === 'map'; this.renderer = this._config.renderer === 'svg' ? 'svg' : 'canvas'; this.tooltips = this._parseTooltips(); @@ -190,6 +198,13 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never contains: 'padding', }; + // If we are showing PointInTimeEventsVisLayers, it means we are showing a base vis + event vis. + // Because this will be using a vconcat spec, we cannot use the default autosize settings, or set + // top-level height/width values. + // See limitations: https://vega.github.io/vega-lite/docs/size.html#limitations + const showPointInTimeEvents = + this.visibleVisLayers?.get(VisLayerTypes.PointInTimeEvents) === true; + let autosize = this.spec.autosize; let useResize = true; @@ -220,6 +235,8 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never contains: string; }; useResize = Boolean(autosize?.type && autosize?.type !== 'none'); + } else if (showPointInTimeEvents) { + autosize = undefined; } else { autosize = defaultAutosize; } @@ -243,7 +260,7 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never ); } - if (useResize) { + if (useResize && !showPointInTimeEvents) { this.spec.width = 'container'; this.spec.height = 'container'; } @@ -304,6 +321,52 @@ 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]) => { + const mark = this.getMarkWithStyle(this.spec.marks, markId); + + if (mark) { + signals.forEach((signal: Signal) => { + signal.on?.forEach((eventObj) => { + // We are prepending mark name here so that the signals only listens to the events on + // the elements related to this mark + eventObj.events = `@${mark.name}:${eventObj.events}`; + }); + }); + this.spec.signals = (this.spec.signals || []).concat(signals); + } + }); + } + } + + /** + * This method recursively looks for a mark that includes the given style. + * Returns undefined if it doesn't find it. + */ + getMarkWithStyle(marks: any[], style: string): any { + 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; + } } /** @@ -386,6 +449,10 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never ) ); } + // Converting the visibleVisLayers array back to a map + if (result.visibleVisLayers !== undefined && Array.isArray(result.visibleVisLayers)) { + result.visibleVisLayers = new Map(result.visibleVisLayers); + } } } return result || {}; diff --git a/src/plugins/vis_type_vega/public/expressions/__mocks__/helpers.ts b/src/plugins/vis_type_vega/public/expressions/__mocks__/helpers.ts new file mode 100644 index 00000000000..0dd50913d5f --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/__mocks__/helpers.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const complexDatatable = + '{"type":"opensearch_dashboards_datatable","rows":[{"col-0-2":1672214400000,"col-1-1":44,"col-2-3":60.9375},{"col-0-2":1672300800000,"col-1-1":150,"col-2-3":82.5},{"col-0-2":1672387200000,"col-1-1":154,"col-2-3":79.5},{"col-0-2":1672473600000,"col-1-1":144,"col-2-3":75.875},{"col-0-2":1672560000000,"col-1-1":133,"col-2-3":259.25},{"col-0-2":1672646400000,"col-1-1":149,"col-2-3":90},{"col-0-2":1672732800000,"col-1-1":152,"col-2-3":79.0625},{"col-0-2":1672819200000,"col-1-1":144,"col-2-3":82.5},{"col-0-2":1672905600000,"col-1-1":166,"col-2-3":85.25},{"col-0-2":1672992000000,"col-1-1":151,"col-2-3":92},{"col-0-2":1673078400000,"col-1-1":143,"col-2-3":90.75},{"col-0-2":1673164800000,"col-1-1":148,"col-2-3":92},{"col-0-2":1673251200000,"col-1-1":146,"col-2-3":83.25},{"col-0-2":1673337600000,"col-1-1":137,"col-2-3":98},{"col-0-2":1673424000000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673510400000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673596800000,"col-1-1":151,"col-2-3":87.4375},{"col-0-2":1673683200000,"col-1-1":157,"col-2-3":63.75},{"col-0-2":1673769600000,"col-1-1":151,"col-2-3":81.5625},{"col-0-2":1673856000000,"col-1-1":152,"col-2-3":100.6875},{"col-0-2":1673942400000,"col-1-1":142,"col-2-3":98},{"col-0-2":1674028800000,"col-1-1":151,"col-2-3":100.8125},{"col-0-2":1674115200000,"col-1-1":163,"col-2-3":83.6875},{"col-0-2":1674201600000,"col-1-1":156,"col-2-3":85.8125},{"col-0-2":1674288000000,"col-1-1":153,"col-2-3":98},{"col-0-2":1674374400000,"col-1-1":162,"col-2-3":75.9375},{"col-0-2":1674460800000,"col-1-1":152,"col-2-3":113.375},{"col-0-2":1674547200000,"col-1-1":159,"col-2-3":73.625},{"col-0-2":1674633600000,"col-1-1":165,"col-2-3":72.8125},{"col-0-2":1674720000000,"col-1-1":153,"col-2-3":113.375},{"col-0-2":1674806400000,"col-1-1":149,"col-2-3":82.5},{"col-0-2":1674892800000,"col-1-1":94,"col-2-3":54}],"columns":[{"id":"col-0-2","name":"order_date per day","meta":{"type":"date_histogram","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{"field":"order_date","timeRange":{"from":"now-90d","to":"now"},"useNormalizedOpenSearchInterval":true,"scaleMetricValues":false,"interval":"auto","drop_partials":false,"min_doc_count":1,"extended_bounds":{}}}},{"id":"col-1-1","name":"Count","meta":{"type":"count","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{}}},{"id":"col-2-3","name":"Max products.min_price","meta":{"type":"max","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{"field":"products.min_price"}}}]}'; +export const complexVisParams = + '{"addLegend":true,"addTimeMarker":true,"addTooltip":true,"categoryAxes":[{"id":"CategoryAxis-1","labels":{"filter":true,"show":true,"truncate":100},"position":"bottom","scale":{"type":"linear"},"show":true,"style":{},"title":{},"type":"category"}],"grid":{"categoryLines":false,"valueAxis":"ValueAxis-1"},"labels":{},"legendPosition":"bottom","seriesParams":[{"data":{"id":"1","label":"Count"},"drawLinesBetweenPoints":true,"interpolate":"linear","lineWidth":2,"mode":"normal","show":true,"showCircles":true,"type":"line","valueAxis":"ValueAxis-1"},{"data":{"id":"3","label":"Max products.min_price"},"drawLinesBetweenPoints":true,"interpolate":"linear","lineWidth":2,"mode":"normal","show":true,"showCircles":true,"type":"line","valueAxis":"ValueAxis-1"}],"thresholdLine":{"color":"#E7664C","show":true,"style":"dashed","value":100,"width":1},"times":[],"type":"line","valueAxes":[{"id":"ValueAxis-1","labels":{"filter":false,"rotate":75,"show":true,"truncate":100},"name":"RightAxis-1","position":"right","scale":{"mode":"normal","type":"linear"},"show":true,"style":{},"title":{"text":"Count"},"type":"value"}]}'; +export const complexDimensions = + '{"x":{"accessor":0,"format":{"id":"date","params":{"pattern":"YYYY-MM-DD"}},"params":{"date":true,"interval":"P1D","intervalESValue":1,"intervalESUnit":"d","format":"YYYY-MM-DD","bounds":{"min":"2022-11-19T03:26:04.730Z","max":"2023-02-17T03:26:04.730Z"}},"label":"order_date per day","aggType":"date_histogram"},"y":[{"accessor":1,"format":{"id":"number"},"params":{},"label":"Count","aggType":"count"},{"accessor":2,"format":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5603","pathname":"/rao/app/visualize","basePath":"/rao"}}},"params":{},"label":"Max products.min_price","aggType":"max"}]}'; + +export const noXAxisDimensions = + '{"x":null,"y":[{"accessor":0,"format":{"id":"number"},"params":{},"label":"Count","aggType":"count"}]}'; + +export const simpleDatatable = + '{"type":"opensearch_dashboards_datatable","rows":[{"col-0-2":1672214400000,"col-1-1":44},{"col-0-2":1672300800000,"col-1-1":150},{"col-0-2":1672387200000,"col-1-1":154},{"col-0-2":1672473600000,"col-1-1":144},{"col-0-2":1672560000000,"col-1-1":133},{"col-0-2":1672646400000,"col-1-1":149},{"col-0-2":1672732800000,"col-1-1":152},{"col-0-2":1672819200000,"col-1-1":144},{"col-0-2":1672905600000,"col-1-1":166},{"col-0-2":1672992000000,"col-1-1":151},{"col-0-2":1673078400000,"col-1-1":143},{"col-0-2":1673164800000,"col-1-1":148},{"col-0-2":1673251200000,"col-1-1":146},{"col-0-2":1673337600000,"col-1-1":137},{"col-0-2":1673424000000,"col-1-1":152},{"col-0-2":1673510400000,"col-1-1":152},{"col-0-2":1673596800000,"col-1-1":151},{"col-0-2":1673683200000,"col-1-1":157},{"col-0-2":1673769600000,"col-1-1":151},{"col-0-2":1673856000000,"col-1-1":152},{"col-0-2":1673942400000,"col-1-1":142},{"col-0-2":1674028800000,"col-1-1":151},{"col-0-2":1674115200000,"col-1-1":163},{"col-0-2":1674201600000,"col-1-1":156},{"col-0-2":1674288000000,"col-1-1":153},{"col-0-2":1674374400000,"col-1-1":162},{"col-0-2":1674460800000,"col-1-1":152},{"col-0-2":1674547200000,"col-1-1":159},{"col-0-2":1674633600000,"col-1-1":165},{"col-0-2":1674720000000,"col-1-1":153},{"col-0-2":1674806400000,"col-1-1":149},{"col-0-2":1674892800000,"col-1-1":94}],"columns":[{"id":"col-0-2","name":"order_date per day","meta":{"type":"date_histogram","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{"field":"order_date","timeRange":{"from":"now-90d","to":"now"},"useNormalizedOpenSearchInterval":true,"scaleMetricValues":false,"interval":"auto","drop_partials":false,"min_doc_count":1,"extended_bounds":{}}}},{"id":"col-1-1","name":"Count","meta":{"type":"count","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{}}}]}'; +export const simpleVisParams = + '{"addLegend":true,"addTimeMarker":false,"addTooltip":true,"categoryAxes":[{"id":"CategoryAxis-1","labels":{"filter":true,"show":true,"truncate":100},"position":"bottom","scale":{"type":"linear"},"show":true,"style":{},"title":{},"type":"category"}],"grid":{"categoryLines":false},"labels":{},"legendPosition":"right","seriesParams":[{"data":{"id":"1","label":"Count"},"drawLinesBetweenPoints":true,"interpolate":"linear","lineWidth":2,"mode":"normal","show":true,"showCircles":true,"type":"line","valueAxis":"ValueAxis-1"}],"thresholdLine":{"color":"#E7664C","show":false,"style":"full","value":10,"width":1},"times":[],"type":"line","valueAxes":[{"id":"ValueAxis-1","labels":{"filter":false,"rotate":0,"show":true,"truncate":100},"name":"LeftAxis-1","position":"left","scale":{"mode":"normal","type":"linear"},"show":true,"style":{},"title":{"text":"Count"},"type":"value"}]}'; +export const simpleDimensions = + '{"x":{"accessor":0,"format":{"id":"date","params":{"pattern":"YYYY-MM-DD"}},"params":{"date":true,"interval":"P1D","intervalESValue":1,"intervalESUnit":"d","format":"YYYY-MM-DD","bounds":{"min":"2022-11-18T00:14:09.617Z","max":"2023-02-16T00:14:09.617Z"}},"label":"order_date per day","aggType":"date_histogram"},"y":[{"accessor":1,"format":{"id":"number"},"params":{},"label":"Count","aggType":"count"}]}'; diff --git a/src/plugins/vis_type_vega/public/expressions/__mocks__/index.ts b/src/plugins/vis_type_vega/public/expressions/__mocks__/index.ts new file mode 100644 index 00000000000..0e8ad44d2bd --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/__mocks__/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './helpers'; diff --git a/src/plugins/vis_type_vega/public/expressions/__snapshots__/helpers.test.js.snap b/src/plugins/vis_type_vega/public/expressions/__snapshots__/helpers.test.js.snap new file mode 100644 index 00000000000..85552a05278 --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/__snapshots__/helpers.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`helpers createSpecFromXYChartDatatable() build complicated line chart" 1`] = `"{\\"$schema\\":\\"https://vega.github.io/schema/vega-lite/v5.json\\",\\"data\\":{\\"values\\":[{\\"col-0-2\\":1672214400000,\\"col-1-1\\":44,\\"col-2-3\\":60.9375},{\\"col-0-2\\":1672300800000,\\"col-1-1\\":150,\\"col-2-3\\":82.5},{\\"col-0-2\\":1672387200000,\\"col-1-1\\":154,\\"col-2-3\\":79.5},{\\"col-0-2\\":1672473600000,\\"col-1-1\\":144,\\"col-2-3\\":75.875},{\\"col-0-2\\":1672560000000,\\"col-1-1\\":133,\\"col-2-3\\":259.25},{\\"col-0-2\\":1672646400000,\\"col-1-1\\":149,\\"col-2-3\\":90},{\\"col-0-2\\":1672732800000,\\"col-1-1\\":152,\\"col-2-3\\":79.0625},{\\"col-0-2\\":1672819200000,\\"col-1-1\\":144,\\"col-2-3\\":82.5},{\\"col-0-2\\":1672905600000,\\"col-1-1\\":166,\\"col-2-3\\":85.25},{\\"col-0-2\\":1672992000000,\\"col-1-1\\":151,\\"col-2-3\\":92},{\\"col-0-2\\":1673078400000,\\"col-1-1\\":143,\\"col-2-3\\":90.75},{\\"col-0-2\\":1673164800000,\\"col-1-1\\":148,\\"col-2-3\\":92},{\\"col-0-2\\":1673251200000,\\"col-1-1\\":146,\\"col-2-3\\":83.25},{\\"col-0-2\\":1673337600000,\\"col-1-1\\":137,\\"col-2-3\\":98},{\\"col-0-2\\":1673424000000,\\"col-1-1\\":152,\\"col-2-3\\":83.6875},{\\"col-0-2\\":1673510400000,\\"col-1-1\\":152,\\"col-2-3\\":83.6875},{\\"col-0-2\\":1673596800000,\\"col-1-1\\":151,\\"col-2-3\\":87.4375},{\\"col-0-2\\":1673683200000,\\"col-1-1\\":157,\\"col-2-3\\":63.75},{\\"col-0-2\\":1673769600000,\\"col-1-1\\":151,\\"col-2-3\\":81.5625},{\\"col-0-2\\":1673856000000,\\"col-1-1\\":152,\\"col-2-3\\":100.6875},{\\"col-0-2\\":1673942400000,\\"col-1-1\\":142,\\"col-2-3\\":98},{\\"col-0-2\\":1674028800000,\\"col-1-1\\":151,\\"col-2-3\\":100.8125},{\\"col-0-2\\":1674115200000,\\"col-1-1\\":163,\\"col-2-3\\":83.6875},{\\"col-0-2\\":1674201600000,\\"col-1-1\\":156,\\"col-2-3\\":85.8125},{\\"col-0-2\\":1674288000000,\\"col-1-1\\":153,\\"col-2-3\\":98},{\\"col-0-2\\":1674374400000,\\"col-1-1\\":162,\\"col-2-3\\":75.9375},{\\"col-0-2\\":1674460800000,\\"col-1-1\\":152,\\"col-2-3\\":113.375},{\\"col-0-2\\":1674547200000,\\"col-1-1\\":159,\\"col-2-3\\":73.625},{\\"col-0-2\\":1674633600000,\\"col-1-1\\":165,\\"col-2-3\\":72.8125},{\\"col-0-2\\":1674720000000,\\"col-1-1\\":153,\\"col-2-3\\":113.375},{\\"col-0-2\\":1674806400000,\\"col-1-1\\":149,\\"col-2-3\\":82.5},{\\"col-0-2\\":1674892800000,\\"col-1-1\\":94,\\"col-2-3\\":54}]},\\"config\\":{\\"view\\":{\\"stroke\\":null},\\"concat\\":{\\"spacing\\":0},\\"legend\\":{\\"orient\\":\\"bottom\\"},\\"kibana\\":{\\"hideWarnings\\":true}},\\"layer\\":[{\\"mark\\":{\\"type\\":\\"line\\",\\"interpolate\\":\\"linear\\",\\"strokeWidth\\":2,\\"point\\":true},\\"encoding\\":{\\"x\\":{\\"axis\\":{\\"title\\":\\"order_date per day\\",\\"grid\\":false},\\"field\\":\\"col-0-2\\",\\"type\\":\\"temporal\\"},\\"y\\":{\\"axis\\":{\\"title\\":\\"Count\\",\\"grid\\":true,\\"orient\\":\\"right\\",\\"labels\\":true,\\"labelAngle\\":75},\\"field\\":\\"col-1-1\\",\\"type\\":\\"quantitative\\"},\\"tooltip\\":[{\\"field\\":\\"col-0-2\\",\\"type\\":\\"temporal\\",\\"title\\":\\"order_date per day\\"},{\\"field\\":\\"col-1-1\\",\\"type\\":\\"quantitative\\",\\"title\\":\\"Count\\"}],\\"color\\":{\\"datum\\":\\"Count\\"}}},{\\"mark\\":{\\"type\\":\\"line\\",\\"interpolate\\":\\"linear\\",\\"strokeWidth\\":2,\\"point\\":true},\\"encoding\\":{\\"x\\":{\\"axis\\":{\\"title\\":\\"order_date per day\\",\\"grid\\":false},\\"field\\":\\"col-0-2\\",\\"type\\":\\"temporal\\"},\\"y\\":{\\"axis\\":{\\"title\\":\\"Count\\",\\"grid\\":true,\\"orient\\":\\"right\\",\\"labels\\":true,\\"labelAngle\\":75},\\"field\\":\\"col-2-3\\",\\"type\\":\\"quantitative\\"},\\"tooltip\\":[{\\"field\\":\\"col-0-2\\",\\"type\\":\\"temporal\\",\\"title\\":\\"order_date per day\\"},{\\"field\\":\\"col-2-3\\",\\"type\\":\\"quantitative\\",\\"title\\":\\"Max products.min_price\\"}],\\"color\\":{\\"datum\\":\\"Max products.min_price\\"}}},{\\"mark\\":\\"rule\\",\\"encoding\\":{\\"x\\":{\\"type\\":\\"temporal\\",\\"field\\":\\"now_field\\"},\\"color\\":{\\"value\\":\\"red\\"},\\"size\\":{\\"value\\":1}}},{\\"mark\\":{\\"type\\":\\"rule\\",\\"color\\":\\"#E7664C\\",\\"strokeDash\\":[8,8]},\\"encoding\\":{\\"y\\":{\\"datum\\":100}}}],\\"transform\\":[{\\"calculate\\":\\"now()\\",\\"as\\":\\"now_field\\"}]}"`; + +exports[`helpers createSpecFromXYChartDatatable() build empty chart if no x-axis is defined" 1`] = `"{\\"$schema\\":\\"https://vega.github.io/schema/vega-lite/v5.json\\",\\"data\\":{\\"values\\":[{\\"col-0-2\\":1672214400000,\\"col-1-1\\":44},{\\"col-0-2\\":1672300800000,\\"col-1-1\\":150},{\\"col-0-2\\":1672387200000,\\"col-1-1\\":154},{\\"col-0-2\\":1672473600000,\\"col-1-1\\":144},{\\"col-0-2\\":1672560000000,\\"col-1-1\\":133},{\\"col-0-2\\":1672646400000,\\"col-1-1\\":149},{\\"col-0-2\\":1672732800000,\\"col-1-1\\":152},{\\"col-0-2\\":1672819200000,\\"col-1-1\\":144},{\\"col-0-2\\":1672905600000,\\"col-1-1\\":166},{\\"col-0-2\\":1672992000000,\\"col-1-1\\":151},{\\"col-0-2\\":1673078400000,\\"col-1-1\\":143},{\\"col-0-2\\":1673164800000,\\"col-1-1\\":148},{\\"col-0-2\\":1673251200000,\\"col-1-1\\":146},{\\"col-0-2\\":1673337600000,\\"col-1-1\\":137},{\\"col-0-2\\":1673424000000,\\"col-1-1\\":152},{\\"col-0-2\\":1673510400000,\\"col-1-1\\":152},{\\"col-0-2\\":1673596800000,\\"col-1-1\\":151},{\\"col-0-2\\":1673683200000,\\"col-1-1\\":157},{\\"col-0-2\\":1673769600000,\\"col-1-1\\":151},{\\"col-0-2\\":1673856000000,\\"col-1-1\\":152},{\\"col-0-2\\":1673942400000,\\"col-1-1\\":142},{\\"col-0-2\\":1674028800000,\\"col-1-1\\":151},{\\"col-0-2\\":1674115200000,\\"col-1-1\\":163},{\\"col-0-2\\":1674201600000,\\"col-1-1\\":156},{\\"col-0-2\\":1674288000000,\\"col-1-1\\":153},{\\"col-0-2\\":1674374400000,\\"col-1-1\\":162},{\\"col-0-2\\":1674460800000,\\"col-1-1\\":152},{\\"col-0-2\\":1674547200000,\\"col-1-1\\":159},{\\"col-0-2\\":1674633600000,\\"col-1-1\\":165},{\\"col-0-2\\":1674720000000,\\"col-1-1\\":153},{\\"col-0-2\\":1674806400000,\\"col-1-1\\":149},{\\"col-0-2\\":1674892800000,\\"col-1-1\\":94}]},\\"config\\":{\\"view\\":{\\"stroke\\":null},\\"concat\\":{\\"spacing\\":0},\\"legend\\":{\\"orient\\":\\"right\\"},\\"kibana\\":{\\"hideWarnings\\":true}},\\"layer\\":[]}"`; + +exports[`helpers createSpecFromXYChartDatatable() build simple line chart" 1`] = `"{\\"$schema\\":\\"https://vega.github.io/schema/vega-lite/v5.json\\",\\"data\\":{\\"values\\":[{\\"col-0-2\\":1672214400000,\\"col-1-1\\":44},{\\"col-0-2\\":1672300800000,\\"col-1-1\\":150},{\\"col-0-2\\":1672387200000,\\"col-1-1\\":154},{\\"col-0-2\\":1672473600000,\\"col-1-1\\":144},{\\"col-0-2\\":1672560000000,\\"col-1-1\\":133},{\\"col-0-2\\":1672646400000,\\"col-1-1\\":149},{\\"col-0-2\\":1672732800000,\\"col-1-1\\":152},{\\"col-0-2\\":1672819200000,\\"col-1-1\\":144},{\\"col-0-2\\":1672905600000,\\"col-1-1\\":166},{\\"col-0-2\\":1672992000000,\\"col-1-1\\":151},{\\"col-0-2\\":1673078400000,\\"col-1-1\\":143},{\\"col-0-2\\":1673164800000,\\"col-1-1\\":148},{\\"col-0-2\\":1673251200000,\\"col-1-1\\":146},{\\"col-0-2\\":1673337600000,\\"col-1-1\\":137},{\\"col-0-2\\":1673424000000,\\"col-1-1\\":152},{\\"col-0-2\\":1673510400000,\\"col-1-1\\":152},{\\"col-0-2\\":1673596800000,\\"col-1-1\\":151},{\\"col-0-2\\":1673683200000,\\"col-1-1\\":157},{\\"col-0-2\\":1673769600000,\\"col-1-1\\":151},{\\"col-0-2\\":1673856000000,\\"col-1-1\\":152},{\\"col-0-2\\":1673942400000,\\"col-1-1\\":142},{\\"col-0-2\\":1674028800000,\\"col-1-1\\":151},{\\"col-0-2\\":1674115200000,\\"col-1-1\\":163},{\\"col-0-2\\":1674201600000,\\"col-1-1\\":156},{\\"col-0-2\\":1674288000000,\\"col-1-1\\":153},{\\"col-0-2\\":1674374400000,\\"col-1-1\\":162},{\\"col-0-2\\":1674460800000,\\"col-1-1\\":152},{\\"col-0-2\\":1674547200000,\\"col-1-1\\":159},{\\"col-0-2\\":1674633600000,\\"col-1-1\\":165},{\\"col-0-2\\":1674720000000,\\"col-1-1\\":153},{\\"col-0-2\\":1674806400000,\\"col-1-1\\":149},{\\"col-0-2\\":1674892800000,\\"col-1-1\\":94}]},\\"config\\":{\\"view\\":{\\"stroke\\":null},\\"concat\\":{\\"spacing\\":0},\\"legend\\":{\\"orient\\":\\"right\\"},\\"kibana\\":{\\"hideWarnings\\":true}},\\"layer\\":[{\\"mark\\":{\\"type\\":\\"line\\",\\"interpolate\\":\\"linear\\",\\"strokeWidth\\":2,\\"point\\":true},\\"encoding\\":{\\"x\\":{\\"axis\\":{\\"title\\":\\"order_date per day\\",\\"grid\\":false},\\"field\\":\\"col-0-2\\",\\"type\\":\\"temporal\\"},\\"y\\":{\\"axis\\":{\\"title\\":\\"Count\\",\\"grid\\":false,\\"orient\\":\\"left\\",\\"labels\\":true,\\"labelAngle\\":0},\\"field\\":\\"col-1-1\\",\\"type\\":\\"quantitative\\"},\\"tooltip\\":[{\\"field\\":\\"col-0-2\\",\\"type\\":\\"temporal\\",\\"title\\":\\"order_date per day\\"},{\\"field\\":\\"col-1-1\\",\\"type\\":\\"quantitative\\",\\"title\\":\\"Count\\"}],\\"color\\":{\\"datum\\":\\"Count\\"}}}]}"`; diff --git a/src/plugins/vis_type_vega/public/expressions/helpers.test.js b/src/plugins/vis_type_vega/public/expressions/helpers.test.js new file mode 100644 index 00000000000..8630b3c69ad --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/helpers.test.js @@ -0,0 +1,233 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + buildLayerMark, + buildXAxis, + buildYAxis, + cleanString, + createSpecFromXYChartDatatable, + formatDatatable, + setupConfig, +} from './helpers'; +import { + complexDatatable, + complexVisParams, + complexDimensions, + simpleDatatable, + simpleVisParams, + simpleDimensions, + noXAxisDimensions, +} from './__mocks__'; +import { + TEST_DATATABLE_NO_VIS_LAYERS, + TEST_DATATABLE_NO_VIS_LAYERS_DIRTY, +} from '../../../vis_augmenter/public'; + +describe('helpers', function () { + describe('formatDatatable()', function () { + it('formatSimpleDatatable', function () { + expect(formatDatatable(TEST_DATATABLE_NO_VIS_LAYERS)).toBe(TEST_DATATABLE_NO_VIS_LAYERS); + }); + it('formatDirtyDatatable', function () { + expect(formatDatatable(TEST_DATATABLE_NO_VIS_LAYERS_DIRTY)).toStrictEqual( + TEST_DATATABLE_NO_VIS_LAYERS + ); + }); + }); + + describe('cleanString()', function () { + it('string should not contain quotation marks', function () { + const dirtyString = '"someString"'; + expect(cleanString(dirtyString)).toBe('someString'); + }); + }); + + describe('setupConfig()', function () { + it('check all legend positions', function () { + const visAugmenterConfig = { + some: 'config', + }; + const baseConfig = { + view: { + stroke: null, + }, + concat: { + spacing: 0, + }, + legend: { + orient: null, + }, + kibana: { + hideWarnings: true, + visAugmenterConfig, + }, + }; + const positions = ['top', 'right', 'left', 'bottom']; + positions.forEach((position) => { + const visParams = { legendPosition: position }; + baseConfig.legend.orient = position; + baseConfig.legend.offset = position === 'top' || position === 'bottom' ? 0 : 18; + expect(setupConfig(visParams, visAugmenterConfig)).toStrictEqual(baseConfig); + }); + }); + }); + + describe('buildLayerMark()', function () { + const types = ['line', 'area', 'histogram']; + const interpolates = ['linear', 'cardinal', 'step-after']; + const strokeWidths = [-1, 0, 1, 2, 3, 4]; + const showCircles = [false, true]; + + it('check each mark possible value', function () { + const mark = { + type: null, + interpolate: null, + strokeWidth: null, + point: null, + }; + types.forEach((type) => { + mark.type = type; + interpolates.forEach((interpolate) => { + mark.interpolate = interpolate; + strokeWidths.forEach((strokeWidth) => { + mark.strokeWidth = strokeWidth; + showCircles.forEach((showCircle) => { + mark.point = showCircle; + const param = { + type: type, + interpolate: interpolate, + lineWidth: strokeWidth, + showCircles: showCircle, + }; + expect(buildLayerMark(param)).toStrictEqual(mark); + }); + }); + }); + }); + }); + }); + + describe('buildXAxis()', function () { + it('build different XAxis', function () { + const xAxisTitle = 'someTitle'; + const xAxisId = 'someId'; + [true, false].forEach((enableGrid) => { + const visParams = { grid: { categoryLines: enableGrid } }; + const vegaXAxis = { + axis: { + title: xAxisTitle, + grid: enableGrid, + }, + field: xAxisId, + type: 'temporal', + }; + expect(buildXAxis(xAxisTitle, xAxisId, visParams)).toStrictEqual(vegaXAxis); + }); + }); + }); + + describe('buildYAxis()', function () { + it('build different YAxis', function () { + const valueAxis = { + id: 'someId', + labels: { + rotate: 75, + show: false, + }, + position: 'left', + title: { + text: 'someText', + }, + }; + const column = { name: 'columnName', id: 'columnId' }; + const visParams = { grid: { valueAxis: true } }; + const vegaYAxis = { + axis: { + title: 'someText', + grid: true, + orient: 'left', + labels: false, + labelAngle: 75, + }, + field: 'columnId', + type: 'quantitative', + }; + expect(buildYAxis(column, valueAxis, visParams)).toStrictEqual(vegaYAxis); + + valueAxis.title.text = '""'; + vegaYAxis.axis.title = 'columnName'; + expect(buildYAxis(column, valueAxis, visParams)).toStrictEqual(vegaYAxis); + }); + it('build YAxis with percentile rank', function () { + const valueAxis = { + id: 'someId', + labels: { + rotate: 75, + show: false, + }, + position: 'left', + title: { + text: 'someText', + }, + }; + const column = { name: 'columnName', id: 'columnId', meta: { type: 'percentile_ranks' } }; + const visParams = { grid: { valueAxis: true } }; + const vegaYAxis = { + axis: { + title: 'someText', + grid: true, + orient: 'left', + labels: false, + labelAngle: 75, + format: '.0%', + }, + field: 'columnId', + type: 'quantitative', + }; + expect(buildYAxis(column, valueAxis, visParams)).toStrictEqual(vegaYAxis); + }); + }); + + describe('createSpecFromXYChartDatatable()', function () { + // Following 3 tests fail since they are persisting temporal data + // which can cause snapshots to fail depending on the test env they are run on. + it.skip('build simple line chart"', function () { + expect( + JSON.stringify( + createSpecFromXYChartDatatable( + formatDatatable(JSON.parse(simpleDatatable)), + JSON.parse(simpleVisParams), + JSON.parse(simpleDimensions) + ) + ) + ).toMatchSnapshot(); + }); + + it.skip('build empty chart if no x-axis is defined"', function () { + expect( + JSON.stringify( + createSpecFromXYChartDatatable( + formatDatatable(JSON.parse(simpleDatatable)), + JSON.parse(simpleVisParams), + JSON.parse(noXAxisDimensions) + ) + ) + ).toMatchSnapshot(); + }); + + it.skip('build complicated line chart"', function () { + expect( + JSON.stringify( + createSpecFromXYChartDatatable( + formatDatatable(JSON.parse(complexDatatable)), + JSON.parse(complexVisParams), + JSON.parse(complexDimensions) + ) + ) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/expressions/helpers.ts b/src/plugins/vis_type_vega/public/expressions/helpers.ts new file mode 100644 index 00000000000..1fffd91b0bd --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/helpers.ts @@ -0,0 +1,281 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + OpenSearchDashboardsDatatable, + OpenSearchDashboardsDatatableColumn, + OpenSearchDashboardsDatatableRow, +} from '../../../expressions/public'; +import { VislibDimensions, VisParams } from '../../../visualizations/public'; +import { isVisLayerColumn, VisAugmenterEmbeddableConfig } from '../../../vis_augmenter/public'; + +// TODO: move this to the visualization plugin that has VisParams once all of these parameters have been better defined +interface ValueAxis { + id: string; + labels: { + filter: boolean; + rotate: number; + show: boolean; + truncate: number; + }; + name: string; + position: string; + scale: { + mode: string; + type: string; + }; + show: true; + style: any; + title: { + text: string; + }; + type: string; +} + +export interface YAxisConfig { + minExtent: number; + maxExtent: number; + offset: number; + translate: number; + domainWidth: number; + labelPadding: number; + titlePadding: number; + tickOffset: number; + tickSize: number; +} + +// 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 +const getXAxisId = (dimensions: any, columns: OpenSearchDashboardsDatatableColumn[]): string => { + return columns.filter((column) => column.name === dimensions.x.label)[0].id; +}; + +export const cleanString = (rawString: string): string => { + return rawString.replaceAll('"', ''); +}; + +// When using autosize features of vega-lite, the chart is expected to reposition +// correctly such that there is space for the chart and legend within the canvas. +// This works for horizontal positions (left/right), but breaks for vertical positions +// (top/bottom). To make up for this, we set the offset to 0 for these positions such that +// the chart will not get truncated or potentially cut off within the canvas. +export const calculateLegendOffset = (legendPosition: string): number => + // 18 is the default offset as of vega lite 5 + legendPosition === 'top' || legendPosition === 'bottom' ? 0 : 18; + +export const formatDatatable = ( + datatable: OpenSearchDashboardsDatatable +): OpenSearchDashboardsDatatable => { + datatable.columns.forEach((column) => { + // clean quotation marks from names in columns + column.name = cleanString(column.name); + // clean ids to remove "." as that will cause vega to not process it correctly. + // This happens for different metric types + column.id = column.id.replaceAll('.', '-'); + }); + + // clean row keys to remove "." as that will cause vega to not process it correctly + const updatedRows: OpenSearchDashboardsDatatableRow[] = datatable.rows.map((row) => + Object.entries(row).reduce((updatedRow, [key, value]) => { + const cleanKey = key.replaceAll('.', '-'); + return Object.assign(updatedRow, { [cleanKey]: value }); + }, {}) + ); + + datatable.rows = updatedRows; + return datatable; +}; + +export const setupConfig = (visParams: VisParams, config: VisAugmenterEmbeddableConfig) => { + const legendPosition = visParams.legendPosition; + return { + view: { + stroke: null, + }, + concat: { + spacing: 0, + }, + legend: { + orient: legendPosition, + offset: calculateLegendOffset(legendPosition), + }, + // This is parsed in the VegaParser and hides unnecessary warnings. + // For example, 'infinite extent' warnings that cover the chart + // when there is empty data for a time series + kibana: { + hideWarnings: true, + visAugmenterConfig: config, + }, + }; +}; + +export const buildLayerMark = (seriesParams: { + type: string; + interpolate: string; + lineWidth: number; + showCircles: boolean; +}) => { + return { + // Possible types are: line, area, histogram. The eligibility checker will + // prevent area and histogram (though area works in vega-lite) + type: seriesParams.type, + // Possible types: linear, cardinal, step-after. All of these types work in vega-lite + interpolate: seriesParams.interpolate, + // The possible values is any number, which matches what vega-lite supports + strokeWidth: seriesParams.lineWidth, + // this corresponds to showing the dots in the visbuilder for each data point + point: seriesParams.showCircles, + }; +}; + +export const buildXAxis = (xAxisTitle: string, xAxisId: string, visParams: VisParams) => { + return { + axis: { + title: xAxisTitle, + grid: visParams.grid.categoryLines, + }, + field: xAxisId, + // Right now, the line charts can only set the x-axis value to be a date attribute, so + // this should always be of type temporal + type: 'temporal', + }; +}; + +export const buildYAxis = ( + column: OpenSearchDashboardsDatatableColumn, + valueAxis: ValueAxis, + visParams: VisParams +) => { + const subAxis = { + title: cleanString(valueAxis.title.text) || column.name, + grid: visParams.grid.valueAxis !== undefined, + orient: valueAxis.position, + labels: valueAxis.labels.show, + labelAngle: valueAxis.labels.rotate, + }; + // Percentile ranks aggregation metric needs percentile formatting. + if (column.meta?.type === 'percentile_ranks') Object.assign(subAxis, { format: '.0%' }); + return { + axis: subAxis, + field: column.id, + type: 'quantitative', + }; +}; + +const isXAxisColumn = (column: OpenSearchDashboardsDatatableColumn): boolean => { + return column.meta?.aggConfigParams?.interval !== undefined; +}; + +// Given a chart's underlying datatable, generate a vega-lite spec. +// Designed to be used with x-y / temporal visualizations only. +export const createSpecFromXYChartDatatable = ( + datatable: OpenSearchDashboardsDatatable, + visParams: VisParams, + dimensions: VislibDimensions, + config: VisAugmenterEmbeddableConfig +): object => { + // TODO: we can try to use VegaSpec type but it is currently very outdated, where many + // of the fields and sub-fields don't have other optional params that we want for customizing. + // For now, we make this more loosely-typed by just specifying it as a generic object. + const spec = {} as any; + + spec.$schema = 'https://vega.github.io/schema/vega-lite/v5.json'; + spec.data = { + values: datatable.rows, + }; + spec.config = setupConfig(visParams, config); + + // Get the valueAxes data and generate a map to easily fetch the different valueAxes data + const valueAxis = new Map(); + visParams?.valueAxes?.forEach((yAxis: ValueAxis) => { + valueAxis.set(yAxis.id, yAxis); + }); + + spec.layer = [] as any[]; + + if (datatable.rows.length > 0 && dimensions.x !== null) { + const xAxisId = getXAxisId(dimensions, datatable.columns); + const xAxisTitle = cleanString(dimensions.x.label); + let seriesParamSkipCount = 0; + datatable.columns.forEach((column, index) => { + // Don't add a layer for x axis column + if (isXAxisColumn(column)) { + seriesParamSkipCount++; + // Don't add a layer for vis layer column + } else if (!isVisLayerColumn(column)) { + const currentSeriesParams = visParams.seriesParams[index - seriesParamSkipCount]; + const currentValueAxis = valueAxis.get(currentSeriesParams.valueAxis.toString()); + let tooltip: Array<{ field: string; type: string; title: string }> = []; + if (visParams.addTooltip) { + tooltip = [ + { field: xAxisId, type: 'temporal', title: xAxisTitle }, + { field: column.id, type: 'quantitative', title: column.name }, + ]; + } + spec.layer.push({ + mark: buildLayerMark(currentSeriesParams), + encoding: { + x: buildXAxis(xAxisTitle, xAxisId, visParams), + y: buildYAxis(column, currentValueAxis, visParams), + tooltip, + color: { + // This ensures all the different metrics have their own distinct and unique color + datum: column.name, + }, + }, + }); + } + }); + } + + if (visParams.addTimeMarker) { + spec.transform = [ + { + calculate: 'now()', + as: 'now_field', + }, + ]; + + spec.layer.push({ + mark: 'rule', + encoding: { + x: { + type: 'temporal', + field: 'now_field', + }, + // The time marker on vislib is red, so keeping this consistent + color: { + value: 'red', + }, + size: { + value: 1, + }, + }, + }); + } + + if (visParams.thresholdLine.show as boolean) { + const layer = { + mark: { + type: 'rule', + color: visParams.thresholdLine.color, + strokeDash: [1, 0], + }, + encoding: { + y: { + datum: visParams.thresholdLine.value, + }, + }, + }; + + // Can only support making a threshold line with full or dashed style, but not dot-dashed + // due to vega-lite limitations + if (visParams.thresholdLine.style !== 'full') { + layer.mark.strokeDash = [8, 8]; + } + spec.layer.push(layer); + } + return spec; +}; diff --git a/src/plugins/vis_type_vega/public/expressions/index.ts b/src/plugins/vis_type_vega/public/expressions/index.ts new file mode 100644 index 00000000000..e85f175d55c --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { LineVegaSpecExpressionFunctionDefinition } from './line_vega_spec_fn'; +export { VegaExpressionFunctionDefinition } from './vega_fn'; +export { YAxisConfig } from './helpers'; diff --git a/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts b/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts new file mode 100644 index 00000000000..63ea5911405 --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts @@ -0,0 +1,107 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cloneDeep, isEmpty } from 'lodash'; +import { i18n } from '@osd/i18n'; +import { + ExpressionFunctionDefinition, + OpenSearchDashboardsDatatable, +} from '../../../expressions/public'; +import { VislibDimensions, VisParams } from '../../../visualizations/public'; +import { + VisLayer, + VisLayers, + PointInTimeEventsVisLayer, + isPointInTimeEventsVisLayer, + addPointInTimeEventsLayersToTable, + addPointInTimeEventsLayersToSpec, + enableVisLayersInSpecConfig, + addVisEventSignalsToSpecConfig, + augmentEventChartSpec, +} from '../../../vis_augmenter/public'; +import { formatDatatable, createSpecFromXYChartDatatable } from './helpers'; +import { VegaVisualizationDependencies } from '../plugin'; + +type Input = OpenSearchDashboardsDatatable; +type Output = Promise; + +interface Arguments { + visLayers: string | null; + visParams: string; + dimensions: string; + visAugmenterConfig: string; +} + +export type LineVegaSpecExpressionFunctionDefinition = ExpressionFunctionDefinition< + 'line_vega_spec', + Input, + Arguments, + Output +>; + +export const createLineVegaSpecFn = ( + dependencies: VegaVisualizationDependencies +): LineVegaSpecExpressionFunctionDefinition => ({ + name: 'line_vega_spec', + type: 'string', + inputTypes: ['opensearch_dashboards_datatable'], + help: i18n.translate('visTypeVega.function.help', { + defaultMessage: 'Construct line vega spec', + }), + args: { + visLayers: { + types: ['string', 'null'], + default: '', + help: '', + }, + visParams: { + types: ['string'], + default: '""', + help: '', + }, + dimensions: { + types: ['string'], + default: '""', + help: '', + }, + visAugmenterConfig: { + types: ['string'], + default: '""', + help: '', + }, + }, + async fn(input, args, context) { + let table = formatDatatable(cloneDeep(input)); + + const visParams = JSON.parse(args.visParams) as VisParams; + const dimensions = JSON.parse(args.dimensions) as VislibDimensions; + const allVisLayers = (args.visLayers ? JSON.parse(args.visLayers) : []) as VisLayers; + const visAugmenterConfig = JSON.parse(args.visAugmenterConfig); + + // currently only supporting PointInTimeEventsVisLayer type + const pointInTimeEventsVisLayers = allVisLayers.filter((visLayer: VisLayer) => + isPointInTimeEventsVisLayer(visLayer) + ) as PointInTimeEventsVisLayer[]; + + if (!isEmpty(pointInTimeEventsVisLayers) && dimensions.x !== null) { + table = addPointInTimeEventsLayersToTable(table, dimensions, pointInTimeEventsVisLayers); + } + + let spec = createSpecFromXYChartDatatable(table, visParams, dimensions, visAugmenterConfig); + + if (!isEmpty(pointInTimeEventsVisLayers) && dimensions.x !== null) { + spec = addPointInTimeEventsLayersToSpec(table, dimensions, spec); + // @ts-ignore + spec.config = enableVisLayersInSpecConfig(spec, pointInTimeEventsVisLayers); + // @ts-ignore + spec.config = addVisEventSignalsToSpecConfig(spec); + } + + // Apply other formatting changes to the spec (show vis data, hide axes, etc.) based on the + // vis augmenter config. Mostly used for customizing the views on the view events flyout. + spec = augmentEventChartSpec(visAugmenterConfig, spec); + return JSON.stringify(spec); + }, +}); diff --git a/src/plugins/vis_type_vega/public/vega_fn.ts b/src/plugins/vis_type_vega/public/expressions/vega_fn.ts similarity index 81% rename from src/plugins/vis_type_vega/public/vega_fn.ts rename to src/plugins/vis_type_vega/public/expressions/vega_fn.ts index abe2d3665ed..6043b851a35 100644 --- a/src/plugins/vis_type_vega/public/vega_fn.ts +++ b/src/plugins/vis_type_vega/public/expressions/vega_fn.ts @@ -35,13 +35,13 @@ import { ExpressionFunctionDefinition, OpenSearchDashboardsContext, Render, -} from '../../expressions/public'; -import { VegaVisualizationDependencies } from './plugin'; -import { createVegaRequestHandler } from './vega_request_handler'; -import { VegaInspectorAdapters } from './vega_inspector/index'; -import { TimeRange, Query } from '../../data/public'; -import { VisRenderValue } from '../../visualizations/public'; -import { VegaParser } from './data_model/vega_parser'; +} from '../../../expressions/public'; +import { VegaVisualizationDependencies } from '../plugin'; +import { createVegaRequestHandler } from '../vega_request_handler'; +import { VegaInspectorAdapters } from '../vega_inspector'; +import { TimeRange, Query } from '../../../data/public'; +import { VisRenderValue } from '../../../visualizations/public'; +import { VegaParser } from '../data_model/vega_parser'; type Input = OpenSearchDashboardsContext | null; type Output = Promise>; @@ -50,7 +50,17 @@ interface Arguments { spec: string; } -export type VisParams = Required; +export interface VisParams { + spec: string; +} + +export type VegaExpressionFunctionDefinition = ExpressionFunctionDefinition< + 'vega', + Input, + Arguments, + Output, + ExecutionContext +>; interface RenderValue extends VisRenderValue { visData: VegaParser; @@ -60,13 +70,7 @@ interface RenderValue extends VisRenderValue { export const createVegaFn = ( dependencies: VegaVisualizationDependencies -): ExpressionFunctionDefinition< - 'vega', - Input, - Arguments, - Output, - ExecutionContext -> => ({ +): VegaExpressionFunctionDefinition => ({ name: 'vega', type: 'render', inputTypes: ['opensearch_dashboards_context', 'null'], diff --git a/src/plugins/vis_type_vega/public/index.ts b/src/plugins/vis_type_vega/public/index.ts index c41add63d68..da9b8b396db 100644 --- a/src/plugins/vis_type_vega/public/index.ts +++ b/src/plugins/vis_type_vega/public/index.ts @@ -35,3 +35,5 @@ import { VegaPlugin as Plugin } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new Plugin(initializerContext); } + +export * from './expressions'; diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index 64ab81eedfd..3967c535136 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -43,13 +43,16 @@ import { setInjectedMetadata, } from './services'; -import { createVegaFn } from './vega_fn'; +import { createVegaFn } from './expressions/vega_fn'; import { createVegaTypeDefinition } from './vega_type'; import { IServiceSettings } from '../../maps_legacy/public'; import './index.scss'; 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 { @@ -72,6 +75,7 @@ export interface VegaPluginSetupDependencies { /** @internal */ export interface VegaPluginStartDependencies { data: DataPublicPluginStart; + uiActions: UiActionsStart; } /** @internal */ @@ -104,13 +108,15 @@ export class VegaPlugin implements Plugin, void> { inspector.registerView(getVegaInspectorView({ uiSettings: core.uiSettings })); expressions.registerFunction(() => createVegaFn(visualizationDependencies)); + expressions.registerFunction(() => createLineVegaSpecFn(visualizationDependencies)); 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 d241b66d472..b67a0959c63 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_request_handler.ts b/src/plugins/vis_type_vega/public/vega_request_handler.ts index 7878e97d588..7711f5d0f49 100644 --- a/src/plugins/vis_type_vega/public/vega_request_handler.ts +++ b/src/plugins/vis_type_vega/public/vega_request_handler.ts @@ -34,7 +34,7 @@ import { SearchAPI } from './data_model/search_api'; import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; -import { VisParams } from './vega_fn'; +import { VisParams } from './expressions/vega_fn'; import { getData, getInjectedMetadata } from './services'; import { VegaInspectorAdapters } from './vega_inspector'; diff --git a/src/plugins/vis_type_vega/public/vega_type.ts b/src/plugins/vis_type_vega/public/vega_type.ts index 03fc05fdee8..8a95f5c1c79 100644 --- a/src/plugins/vis_type_vega/public/vega_type.ts +++ b/src/plugins/vis_type_vega/public/vega_type.ts @@ -74,7 +74,7 @@ export const createVegaTypeDefinition = ( showFilterBar: true, }, getSupportedTriggers: () => { - return [VIS_EVENT_TO_TRIGGER.applyFilter]; + return [VIS_EVENT_TO_TRIGGER.applyFilter, VIS_EVENT_TO_TRIGGER.externalAction]; }, getUsedIndexPattern: async (visParams) => { try { 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 657bd04403e..71a7dd6cb48 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 @@ -51,6 +51,7 @@ const vegaFunctions = { opensearchDashboardsRemoveFilter: 'removeFilterHandler', opensearchDashboardsRemoveAllFilters: 'removeAllFiltersHandler', opensearchDashboardsSetTimeFilter: 'setTimeFilterHandler', + opensearchDashboardsVisEventTriggered: 'triggerExternalActionHandler', }; for (const funcName of Object.keys(vegaFunctions)) { @@ -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; @@ -259,6 +261,25 @@ export class VegaBaseView { return false; } + // This is only used when the PointInTimeEventsVisLayer type in vega parser's + // visibleVisLayers is true. This VisLayer type is currently only supported + // for time series chart types. It may be updated + // in the future to expand to other chart type use cases. + // Because there is no clean way to use autosize for concatenated views, + // we manually set the value of the top view (the base vis) to fill in + // space and leave enough space to show the bottom view (the events vis). + // Ref: https://vega.github.io/vega-lite/docs/size.html#limitations + addPointInTimeEventPadding(view) { + // This value represents the pixel height of the event canvas. It is determined + // based on the event mark size, such that there is sufficient but minimal space + // needed to render the event marks. + const eventVisHeight = 100; + const height = Math.max(0, this._$container.height()) - eventVisHeight; + if (view._signals.concat_0_height !== undefined) { + view._signals.concat_0_height.value = height; + } + } + setView(view) { if (this._view === view) return; @@ -327,6 +348,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 @@ -442,7 +475,11 @@ export class VegaBaseView { * Set global debug variable to simplify vega debugging in console. Show info message first time */ setDebugValues(view, spec, vlspec) { - this._parser.searchAPI.inspectorAdapters?.vega.bindInspectValues({ + // The vega inspector can now be null when rendering line charts using vega for the overlay visualization feature. + // This is because the inspectors get added at bootstrap to the different chart types and visualize embeddable + // thinks the line chart is vislib line chart and uses that inspector adapter and has no way of knowing it's + // actually a vega-lite chart and needs to use the vega inspector adapter without hacky code. + this._parser.searchAPI.inspectorAdapters?.vega?.bindInspectValues({ view, spec: vlspec || spec, }); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_view.js index 45d15849ed4..9a1bc6bcb94 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_view.js @@ -28,6 +28,12 @@ * under the License. */ +import { get } from 'lodash'; +import { + VisLayerTypes, + calculateYAxisPadding, + VisFlyoutContext, +} from '../../../vis_augmenter/public'; import { vega } from '../lib/vega'; import { VegaBaseView } from './vega_base_view'; @@ -44,6 +50,60 @@ export class VegaView extends VegaBaseView { view.warn = this.onWarn.bind(this); view.error = this.onError.bind(this); if (this._parser.useResize) this.updateVegaSize(view); + + const showPointInTimeEvents = + this._parser.visibleVisLayers?.get(VisLayerTypes.PointInTimeEvents) === true; + + if (showPointInTimeEvents) { + this.addPointInTimeEventPadding(view); + const inFlyout = get(this, '_parser.visAugmenterConfig.inFlyout', false); + const flyoutContext = get( + this, + '_parser.visAugmenterConfig.flyoutContext', + VisFlyoutContext.BASE_VIS + ); + const leftValueAxisPadding = get( + this, + '_parser.visAugmenterConfig.leftValueAxisPadding', + false + ); + const rightValueAxisPadding = get( + this, + '_parser.visAugmenterConfig.rightValueAxisPadding', + false + ); + const yAxisConfig = get(this, '_parser.vlspec.config.axisY', {}); + + // Autosizing is needed here since autosize won't be set correctly when there is PointInTimeEventLayers. + // This is because these layers cause the spec to use `vconcat` under the hood to stack the base chart + // with the event chart. Autosize doesn't work at the vega-lite level, so we set here at the vega level. + // Details here: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3485#issuecomment-1507442348 + view.autosize({ + type: 'fit', + contains: 'padding', + }); + + if (inFlyout) { + const yAxisPadding = calculateYAxisPadding(yAxisConfig); + view.padding({ + ...view.padding(), + // If we are displaying an event chart (no vis data), then we need to offset the chart + // to align the data / events. We do this by checking if padding is needed on the left + // and/or right, and adding padding based on the y axis config. + left: + leftValueAxisPadding && + (flyoutContext === VisFlyoutContext.EVENT_VIS || + flyoutContext === VisFlyoutContext.TIMELINE_VIS) + ? yAxisPadding + : 0, + right: + rightValueAxisPadding && flyoutContext === VisFlyoutContext.EVENT_VIS + ? yAxisPadding + : 0, + }); + } + } + view.initialize(this._$container.get(0), this._$controls.get(0)); if (this._parser.useHover) view.hover(); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.js b/src/plugins/vis_type_vega/public/vega_visualization.js index af5c58f8a14..379670bda41 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.js @@ -90,6 +90,7 @@ export const createVegaVisualization = ({ getServiceSettings }) => serviceSettings, filterManager, timefilter, + externalAction: this._vis.API.events.externalAction, }; if (vegaParser.useMap) { diff --git a/src/plugins/vis_type_vislib/opensearch_dashboards.json b/src/plugins/vis_type_vislib/opensearch_dashboards.json index b0d9627bb10..c498d121e99 100644 --- a/src/plugins/vis_type_vislib/opensearch_dashboards.json +++ b/src/plugins/vis_type_vislib/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["charts", "data", "expressions", "visualizations", "opensearchDashboardsLegacy"], + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "opensearchDashboardsLegacy", "visTypeVega"], "optionalPlugins": ["visTypeXy"], - "requiredBundles": ["opensearchDashboardsUtils", "visDefaultEditor"] + "requiredBundles": ["opensearchDashboardsUtils", "visDefaultEditor", "visAugmenter"] } diff --git a/src/plugins/vis_type_vislib/public/line.ts b/src/plugins/vis_type_vislib/public/line.ts index 06a5be4fe41..04ae732e290 100644 --- a/src/plugins/vis_type_vislib/public/line.ts +++ b/src/plugins/vis_type_vislib/public/line.ts @@ -50,6 +50,7 @@ import { createVislibVisController } from './vis_controller'; import { VisTypeVislibDependencies } from './plugin'; import { Rotates } from '../../charts/public'; import { VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; +import { toExpressionAst } from './line_to_expression'; export const createLineVisTypeDefinition = (deps: VisTypeVislibDependencies) => ({ name: 'line', @@ -58,6 +59,7 @@ export const createLineVisTypeDefinition = (deps: VisTypeVislibDependencies) => description: i18n.translate('visTypeVislib.line.lineDescription', { defaultMessage: 'Emphasize trends', }), + toExpressionAst, visualization: createVislibVisController(deps), getSupportedTriggers: () => { return [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush]; diff --git a/src/plugins/vis_type_vislib/public/line_to_expression.ts b/src/plugins/vis_type_vislib/public/line_to_expression.ts new file mode 100644 index 00000000000..8650c601380 --- /dev/null +++ b/src/plugins/vis_type_vislib/public/line_to_expression.ts @@ -0,0 +1,72 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get } from 'lodash'; +import { buildVislibDimensions, Vis, VislibDimensions } from '../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../expressions/public'; +import { OpenSearchaggsExpressionFunctionDefinition } from '../../data/common/search/expressions'; +import { + VegaExpressionFunctionDefinition, + LineVegaSpecExpressionFunctionDefinition, +} from '../../vis_type_vega/public'; +import { isEligibleForVisLayers, VisAugmenterEmbeddableConfig } from '../../vis_augmenter/public'; + +export const toExpressionAst = async (vis: Vis, params: any) => { + // Construct the existing expr fns that are ran for vislib line chart, up until the render fn. + // That way we get the exact same data table of results as if it was a vislib chart. + const opensearchaggsFn = buildExpressionFunction( + 'opensearchaggs', + { + index: vis.data.indexPattern!.id!, + metricsAtAllLevels: vis.isHierarchical(), + partialRows: vis.params.showPartialRows || false, + aggConfigs: JSON.stringify(vis.data.aggs!.aggs), + includeFormatHints: false, + } + ); + + // Checks if there are vislayers to overlay. If not, default to the vislib implementation. + const dimensions: VislibDimensions = await buildVislibDimensions(vis, params); + if ( + params.visLayers == null || + Object.keys(params.visLayers).length === 0 || + !isEligibleForVisLayers(vis) + ) { + // Render using vislib instead of vega-lite + const visConfig = { ...vis.params, dimensions }; + const vislib = buildExpressionFunction('vislib', { + type: 'line', + visConfig: JSON.stringify(visConfig), + }); + const ast = buildExpression([opensearchaggsFn, vislib]); + return ast.toAst(); + } else { + const visAugmenterConfig = get( + params, + 'visAugmenterConfig', + {} + ) as VisAugmenterEmbeddableConfig; + + // adding the new expr fn here that takes the datatable and converts to a vega spec + const vegaSpecFn = buildExpressionFunction( + 'line_vega_spec', + { + visLayers: JSON.stringify(params.visLayers), + visParams: JSON.stringify(vis.params), + dimensions: JSON.stringify(dimensions), + visAugmenterConfig: JSON.stringify(visAugmenterConfig), + } + ); + const vegaSpecFnExpressionBuilder = buildExpression([vegaSpecFn]); + + // build vega expr fn. use nested expression fn syntax to first construct the + // spec via 'line_vega_spec' fn, then set as the arg for the final 'vega' fn + const vegaFn = buildExpressionFunction('vega', { + spec: vegaSpecFnExpressionBuilder, + }); + const ast = buildExpression([opensearchaggsFn, vegaFn]); + return ast.toAst(); + } +}; diff --git a/src/plugins/visualizations/opensearch_dashboards.json b/src/plugins/visualizations/opensearch_dashboards.json index 6223ffce380..b7c5e4ab9b4 100644 --- a/src/plugins/visualizations/opensearch_dashboards.json +++ b/src/plugins/visualizations/opensearch_dashboards.json @@ -5,5 +5,5 @@ "ui": true, "requiredPlugins": ["data", "expressions", "uiActions", "embeddable", "inspector", "dashboard"], "optionalPlugins": ["usageCollection"], - "requiredBundles": ["opensearchDashboardsUtils", "discover", "savedObjects"] + "requiredBundles": ["opensearchDashboardsUtils", "discover", "savedObjects", "visAugmenter"] } diff --git a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts index 03666a199dc..2c937847ee6 100644 --- a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts +++ b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts @@ -44,6 +44,7 @@ import { getHttp, getTimeFilter, getCapabilities, + getSavedAugmentVisLoader, } from '../services'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants'; @@ -88,6 +89,8 @@ export const createVisEmbeddableFromObject = (deps: VisualizeEmbeddableFactoryDe const editable = getCapabilities().visualize.save as boolean; + const savedAugmentVisLoader = getSavedAugmentVisLoader(); + return new VisualizeEmbeddable( getTimeFilter(), { @@ -101,6 +104,7 @@ export const createVisEmbeddableFromObject = (deps: VisualizeEmbeddableFactoryDe input, attributeService, savedVisualizationsLoader, + savedAugmentVisLoader, parent ); } catch (e) { diff --git a/src/plugins/visualizations/public/embeddable/events.ts b/src/plugins/visualizations/public/embeddable/events.ts index 2a17ef9d5ef..3d34cfe4995 100644 --- a/src/plugins/visualizations/public/embeddable/events.ts +++ b/src/plugins/visualizations/public/embeddable/events.ts @@ -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, }; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index f3808951d51..e47052bfcd5 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -28,7 +28,7 @@ * under the License. */ -import _, { get } from 'lodash'; +import _, { get, isEmpty } from 'lodash'; import { Subscription } from 'rxjs'; import * as Rx from 'rxjs'; import { i18n } from '@osd/i18n'; @@ -57,13 +57,26 @@ import { } from '../../../expressions/public'; import { buildPipeline } from '../legacy/build_pipeline'; import { Vis, SerializedVis } from '../vis'; -import { getExpressions, getUiActions } from '../services'; +import { getExpressions, getNotifications, getUiActions } from '../services'; import { VIS_EVENT_TO_TRIGGER } from './events'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; import { TriggerId } from '../../../ui_actions/public'; import { SavedObjectAttributes } from '../../../../core/types'; -import { AttributeService } from '../../../dashboard/public'; +import { AttributeService, DASHBOARD_CONTAINER_TYPE } from '../../../dashboard/public'; import { SavedVisualizationsLoader } from '../saved_visualizations'; +import { + SavedAugmentVisLoader, + ExprVisLayers, + VisLayers, + isEligibleForVisLayers, + getAugmentVisSavedObjs, + buildPipelineFromAugmentVisSavedObjs, + getAnyErrors, + AugmentVisContext, + VisLayer, + VisAugmenterEmbeddableConfig, + PLUGIN_RESOURCE_DELETE_TRIGGER, +} from '../../../vis_augmenter/public'; import { VisSavedObject } from '../types'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -83,6 +96,11 @@ export interface VisualizeInput extends EmbeddableInput { }; savedVis?: SerializedVis; table?: unknown; + // TODO: This config, along with other VisAugmenter-related fields (visLayers, savedAugmentVisLoader) + // can be decoupled from embeddables plugin entirely. It is only used for changing the underlying + // visualization. Instead, we can use ReactExpressionRenderer for handling the rendering. + // For details, see https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4483 + visAugmenterConfig?: VisAugmenterEmbeddableConfig; } export interface VisualizeOutput extends EmbeddableOutput { @@ -114,7 +132,7 @@ export class VisualizeEmbeddable private visCustomizations?: Pick; private subscriptions: Subscription[] = []; private expression: string = ''; - private vis: Vis; + public vis: Vis; private domNode: any; public readonly type = VISUALIZE_EMBEDDABLE_TYPE; private autoRefreshFetchSubscription: Subscription; @@ -127,6 +145,9 @@ export class VisualizeEmbeddable VisualizeByReferenceInput >; private savedVisualizationsLoader?: SavedVisualizationsLoader; + private savedAugmentVisLoader?: SavedAugmentVisLoader; + public visLayers?: VisLayer[]; + private visAugmenterConfig?: VisAugmenterEmbeddableConfig; constructor( timefilter: TimefilterContract, @@ -138,6 +159,7 @@ export class VisualizeEmbeddable VisualizeByReferenceInput >, savedVisualizationsLoader?: SavedVisualizationsLoader, + savedAugmentVisLoader?: SavedAugmentVisLoader, parent?: IContainer ) { super( @@ -160,7 +182,8 @@ export class VisualizeEmbeddable this.vis.uiState.on('reload', this.reload); this.attributeService = attributeService; this.savedVisualizationsLoader = savedVisualizationsLoader; - + this.savedAugmentVisLoader = savedAugmentVisLoader; + this.visAugmenterConfig = initialInput.visAugmenterConfig; this.autoRefreshFetchSubscription = timefilter .getAutoRefreshFetch$() .subscribe(this.updateHandler.bind(this)); @@ -336,6 +359,10 @@ export class VisualizeEmbeddable timeFieldName: this.vis.data.indexPattern?.timeFieldName!, ...event.data, }; + } else if (triggerId === VIS_EVENT_TO_TRIGGER.externalAction) { + context = { + savedObjectId: this.vis.id, + } as AugmentVisContext; } else { context = { embeddable: this, @@ -393,10 +420,23 @@ export class VisualizeEmbeddable } this.abortController = new AbortController(); const abortController = this.abortController; + + // By waiting for this to complete, this.visLayers will be populated. + // Note we only fetch when in the context of a dashboard or in the view + // events flyout - we do not show events or have event functionality when + // in the vis edit view. + const shouldFetchVisLayers = + this.parent?.type === DASHBOARD_CONTAINER_TYPE || this.visAugmenterConfig?.inFlyout; + if (shouldFetchVisLayers) { + await this.populateVisLayers(); + } + this.expression = await buildPipeline(this.vis, { timefilter: this.timefilter, timeRange: this.timeRange, abortSignal: this.abortController!.signal, + visLayers: this.visLayers, + visAugmenterConfig: this.visAugmenterConfig, }); if (this.handler && !abortController.signal.aborted) { @@ -465,4 +505,92 @@ export class VisualizeEmbeddable { showSaveModal: true, saveModalTitle } ); }; + + /** + * Fetches any VisLayers, and filters out to only include ones in the list of + * input resource IDs, if specified. Assigns them to this.visLayers. + * Note this fn is public so we can fetch vislayers on demand when needed, + * e.g., generating other vis embeddables in the view events flyout. + */ + public async populateVisLayers(): Promise { + const visLayers = await this.fetchVisLayers(); + this.visLayers = + this.visAugmenterConfig?.visLayerResourceIds === undefined + ? visLayers + : visLayers.filter((visLayer) => + this.visAugmenterConfig.visLayerResourceIds.includes(visLayer.pluginResource.id) + ); + } + + /** + * Collects any VisLayers from plugin expressions functions + * by fetching all AugmentVisSavedObjects that match the vis + * saved object ID. + */ + fetchVisLayers = async (): Promise => { + try { + const expressionParams: IExpressionLoaderParams = { + searchContext: { + timeRange: this.timeRange, + query: this.input.query, + filters: this.input.filters, + }, + uiState: this.vis.uiState, + inspectorAdapters: this.inspectorAdapters, + }; + const aborted = get(this.abortController, 'signal.aborted', false) as boolean; + const augmentVisSavedObjs = await getAugmentVisSavedObjs( + this.vis.id, + this.savedAugmentVisLoader + ); + + if (!isEmpty(augmentVisSavedObjs) && !aborted && isEligibleForVisLayers(this.vis)) { + const visLayersPipeline = buildPipelineFromAugmentVisSavedObjs(augmentVisSavedObjs); + // The initial input for the pipeline will just be an empty arr of VisLayers. As plugin + // expression functions are ran, they will incrementally append their generated VisLayers to it. + const visLayersPipelineInput = { + type: 'vis_layers', + layers: [] as VisLayers, + }; + // We cannot use this.handler in this case, since it does not support the run() cmd + // we need here. So, we consume the expressions service to run this directly instead. + const exprVisLayers = (await getExpressions().run( + visLayersPipeline, + visLayersPipelineInput, + expressionParams as Record + )) as ExprVisLayers; + const visLayers = exprVisLayers.layers; + + /** + * There may be some stale saved objs if any plugin resources have been deleted since last time + * data was fetched from them via the expression functions. Execute this trigger so any listening + * action can perform cleanup. + * + * TODO: this should be automatically handled by the saved objects plugin. Tracking issue: + * https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4499 + */ + getUiActions().getTrigger(PLUGIN_RESOURCE_DELETE_TRIGGER).exec({ + savedObjs: augmentVisSavedObjs, + visLayers, + }); + + const err = getAnyErrors(visLayers, this.vis.title); + // This is only true when one or more VisLayers has an error + if (err !== undefined) { + const { toasts } = getNotifications(); + toasts.addError(err, { + title: i18n.translate('visualizations.renderVisTitle', { + defaultMessage: `Error loading data on the ${this.vis.title} chart`, + }), + toastMessage: ' ', + id: this.id, + }); + } + return visLayers; + } + } catch { + return [] as VisLayers; + } + return [] as VisLayers; + }; } diff --git a/src/plugins/visualizations/public/expressions/vis.ts b/src/plugins/visualizations/public/expressions/vis.ts index acf747973de..02f13ab2ad8 100644 --- a/src/plugins/visualizations/public/expressions/vis.ts +++ b/src/plugins/visualizations/public/expressions/vis.ts @@ -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 { @@ -99,6 +100,10 @@ export class ExprVis extends EventEmitter { if (!this.eventsSubject) return; this.eventsSubject.next({ name: 'applyFilter', data }); }, + externalAction: (data: any) => { + if (!this.eventsSubject) return; + this.eventsSubject.next({ name: 'externalAction', data }); + }, }, }; } diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 46d3b3dd7d0..6c8cf4ec51d 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -41,9 +41,18 @@ 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, buildVislibDimensions } from './legacy/build_pipeline'; +export { + getSchemas as getVisSchemas, + buildVislibDimensions, + VislibDimensions, +} from './legacy/build_pipeline'; /** @public types */ export { VisualizationsSetup, VisualizationsStart }; @@ -67,3 +76,4 @@ export { export { ExprVisAPIEvents } from './expressions/vis'; export { VisualizationListItem } from './vis_types/vis_type_alias_registry'; export { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; +export { createSavedVisLoader } from './saved_visualizations'; diff --git a/src/plugins/visualizations/public/legacy/build_pipeline.ts b/src/plugins/visualizations/public/legacy/build_pipeline.ts index 1cbb3bc3887..de41a7a48c0 100644 --- a/src/plugins/visualizations/public/legacy/build_pipeline.ts +++ b/src/plugins/visualizations/public/legacy/build_pipeline.ts @@ -33,6 +33,8 @@ import moment from 'moment'; import { formatExpression, SerializedFieldFormat } from '../../../../plugins/expressions/public'; import { IAggConfig, search, TimefilterContract } from '../../../../plugins/data/public'; import { Vis, VisParams } from '../types'; +import { VisAugmenterEmbeddableConfig, VisLayers } from '../../../../plugins/vis_augmenter/public'; + const { isDateHistogramBucketAggConfig } = search.aggs; interface SchemaConfigParams { @@ -85,6 +87,8 @@ export interface BuildPipelineParams { timefilter: TimefilterContract; timeRange?: any; abortSignal?: AbortSignal; + visLayers?: VisLayers; + visAugmenterConfig?: VisAugmenterEmbeddableConfig; } const vislibCharts: string[] = [ @@ -331,7 +335,20 @@ const buildVisConfig: BuildVisConfigFunction = { }, }; -export const buildVislibDimensions = async (vis: any, params: BuildPipelineParams) => { +export interface VislibDimensions { + x: any; + y: SchemaConfig[]; + z?: any[]; + width?: any[]; + series?: any[]; + splitRow?: any[]; + splitColumn?: any[]; +} + +export const buildVislibDimensions = async ( + vis: any, + params: BuildPipelineParams +): Promise => { const schemas = getSchemas(vis, { timeRange: params.timeRange, timefilter: params.timefilter, diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 682e678ed58..3542e0cc26f 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -37,6 +37,7 @@ import { Plugin, ApplicationStart, SavedObjectsClientContract, + NotificationsStart, } from '../../../core/public'; import { TypesService, TypesSetup, TypesStart } from './vis_types'; import { @@ -54,12 +55,14 @@ import { setExpressions, setUiActions, setSavedVisualizationsLoader, + setSavedAugmentVisLoader, setTimeFilter, setAggs, setChrome, setOverlays, setSavedSearchLoader, setEmbeddable, + setNotifications, } from './services'; import { VISUALIZE_EMBEDDABLE_TYPE, @@ -92,6 +95,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. @@ -128,6 +132,7 @@ export interface VisualizationsStartDeps { dashboard: DashboardStart; getAttributeService: DashboardStart['getAttributeService']; savedObjectsClient: SavedObjectsClientContract; + notifications: NotificationsStart; } /** @@ -177,6 +182,14 @@ export class VisualizationsPlugin { data, expressions, uiActions, embeddable, dashboard }: VisualizationsStartDeps ): VisualizationsStart { const types = this.types.start(); + const savedAugmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + }); + setSavedAugmentVisLoader(savedAugmentVisLoader); setI18n(core.i18n); setTypes(types); setEmbeddable(embeddable); @@ -210,6 +223,7 @@ export class VisualizationsPlugin overlays: core.overlays, }); setSavedSearchLoader(savedSearchLoader); + setNotifications(core.notifications); return { ...types, showNewVisModal, diff --git a/src/plugins/visualizations/public/services.ts b/src/plugins/visualizations/public/services.ts index e3f3ba56f6b..a99a7010af2 100644 --- a/src/plugins/visualizations/public/services.ts +++ b/src/plugins/visualizations/public/services.ts @@ -37,6 +37,7 @@ import { IUiSettingsClient, OverlayStart, SavedObjectsStart, + NotificationsStart, } from '../../../core/public'; import { TypesStart } from './vis_types'; import { createGetterSetter } from '../../opensearch_dashboards_utils/common'; @@ -52,6 +53,7 @@ import { UiActionsStart } from '../../ui_actions/public'; import { SavedVisualizationsLoader } from './saved_visualizations'; import { SavedObjectLoader } from '../../saved_objects/public'; import { EmbeddableStart } from '../../embeddable/public'; +import { SavedObjectLoaderAugmentVis } from '../../vis_augmenter/public'; export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); @@ -106,3 +108,11 @@ export const [getChrome, setChrome] = createGetterSetter('Chrome'); export const [getSavedSearchLoader, setSavedSearchLoader] = createGetterSetter( 'savedSearchLoader' ); + +export const [getNotifications, setNotifications] = createGetterSetter( + 'Notifications' +); + +export const [getSavedAugmentVisLoader, setSavedAugmentVisLoader] = createGetterSetter< + SavedObjectLoaderAugmentVis +>('savedAugmentVisLoader'); diff --git a/src/plugins/visualize/opensearch_dashboards.json b/src/plugins/visualize/opensearch_dashboards.json index c898f7da377..47573b58b9d 100644 --- a/src/plugins/visualize/opensearch_dashboards.json +++ b/src/plugins/visualize/opensearch_dashboards.json @@ -19,6 +19,7 @@ "opensearchDashboardsReact", "home", "discover", - "visDefaultEditor" + "visDefaultEditor", + "savedObjectsManagement" ] } diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 46db44bb066..5dab2f11051 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -29,7 +29,6 @@ */ import './visualize_listing.scss'; - import React, { useCallback, useRef, useMemo, useEffect } from 'react'; import { i18n } from '@osd/i18n'; import { useUnmount, useMount } from 'react-use'; @@ -43,6 +42,8 @@ import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../visualizations/public import { VisualizeServices } from '../types'; import { VisualizeConstants } from '../visualize_constants'; import { getTableColumns, getNoItemsMessage } from '../utils'; +import { getUiActions } from '../../services'; +import { SAVED_OBJECT_DELETE_TRIGGER } from '../../../../saved_objects_management/public'; export const VisualizeListing = () => { const { @@ -134,15 +135,29 @@ export const VisualizeListing = () => { const deleteItems = useCallback( async (selectedItems: object[]) => { + const uiActions = getUiActions(); await Promise.all( - selectedItems.map((item: any) => savedObjects.client.delete(item.savedObjectType, item.id)) - ).catch((error) => { - toastNotifications.addError(error, { - title: i18n.translate('visualize.visualizeListingDeleteErrorTitle', { - defaultMessage: 'Error deleting visualization', - }), - }); - }); + selectedItems.map((item: any) => + savedObjects.client + .delete(item.savedObjectType, item.id) + .then(() => { + /** + * TODO: this should be automatically handled by the saved objects plugin. Tracking issue: + * https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4499 + */ + uiActions + .getTrigger(SAVED_OBJECT_DELETE_TRIGGER) + .exec({ type: item.savedObjectType, savedObjectId: item.id }); + }) + .catch((error) => { + toastNotifications.addError(error, { + title: i18n.translate('visualize.visualizeListingDeleteErrorTitle', { + defaultMessage: 'Error deleting visualization', + }), + }); + }) + ) + ); }, [savedObjects.client, toastNotifications] ); diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 297db26c48d..c146efef1fa 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -60,13 +60,14 @@ import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { SavedObjectsStart } from '../../saved_objects/public'; import { EmbeddableStart } from '../../embeddable/public'; import { DashboardStart } from '../../dashboard/public'; -import { UiActionsSetup, VISUALIZE_FIELD_TRIGGER } from '../../ui_actions/public'; +import { UiActionsSetup, UiActionsStart, VISUALIZE_FIELD_TRIGGER } from '../../ui_actions/public'; import { setUISettings, setApplication, setIndexPatterns, setQueryService, setShareService, + setUiActions, } from './services'; import { visualizeFieldAction } from './actions/visualize_field_action'; import { createVisualizeUrlGenerator } from './url_generator'; @@ -80,6 +81,7 @@ export interface VisualizePluginStartDependencies { urlForwarding: UrlForwardingStart; savedObjects: SavedObjectsStart; dashboard: DashboardStart; + uiActions: UiActionsStart; } export interface VisualizePluginSetupDependencies { @@ -248,6 +250,7 @@ export class VisualizePlugin if (plugins.share) { setShareService(plugins.share); } + setUiActions(plugins.uiActions); } stop() { diff --git a/src/plugins/visualize/public/services.ts b/src/plugins/visualize/public/services.ts index c0f359e8a00..ac367522ab7 100644 --- a/src/plugins/visualize/public/services.ts +++ b/src/plugins/visualize/public/services.ts @@ -32,6 +32,7 @@ import { ApplicationStart, IUiSettingsClient } from '../../../core/public'; import { createGetterSetter } from '../../../plugins/opensearch_dashboards_utils/public'; import { IndexPatternsContract, DataPublicPluginStart } from '../../../plugins/data/public'; import { SharePluginStart } from '../../share/public'; +import { UiActionsStart } from '../../ui_actions/public'; export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); @@ -46,3 +47,5 @@ export const [getIndexPatterns, setIndexPatterns] = createGetterSetter('Query'); + +export const [getUiActions, setUiActions] = createGetterSetter('UIActions'); diff --git a/yarn.lock b/yarn.lock index 6966b1f83da..d6057d75f96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18927,4 +18927,4 @@ zlib@^1.0.5: zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" - integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw== + integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw== \ No newline at end of file